| author | |
| committer | |
| log | 9547cf24634ccafe5d381964449b722d33f812b8 |
| tree | c4ab6876aa34c074638cbe1919d9f060e6cd4497 |
| parent | 1c7d059f550a53dd94d5b9802640ecd11b759d1a |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude <noreply@anthropic.com>2 files changed, 102 insertions(+), 4 deletions(-)
apps/server/src/process/externalLauncher.test.ts+65| ... | ... | @@ -2,6 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | 2 | import { assert, it } from "@effect/vitest"; |
| 3 | 3 | import * as ConfigProvider from "effect/ConfigProvider"; |
| 4 | 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Fiber from "effect/Fiber"; | |
| 5 | 6 | import * as FileSystem from "effect/FileSystem"; |
| 6 | 7 | import * as Layer from "effect/Layer"; |
| 7 | 8 | import * as Path from "effect/Path"; |
| ... | ... | @@ -216,6 +217,70 @@ it.effect("memoizes editor discovery and refreshes after the cache window", () = |
| 216 | 217 | ); |
| 217 | 218 | }); |
| 218 | 219 | |
| 220 | // A client that disconnects mid-scan interrupts the shared discovery effect on | |
| 221 | // the connection fiber. The cache must not retain that interrupt: doing so | |
| 222 | // replayed it to every later connect for the whole TTL, so `server.getConfig` | |
| 223 | // failed and no client could reconnect until the server restarted. | |
| 224 | it.effect("rescans after an interrupted discovery instead of caching the interrupt", () => { | |
| 225 | const fileInfo = { type: "File" } as FileSystem.File.Info; | |
| 226 | let blockFirstScan = true; | |
| 227 | let scans = 0; | |
| 228 | const launcherLayer = ExternalLauncher.layer.pipe( | |
| 229 | Layer.provide( | |
| 230 | Layer.mergeAll( | |
| 231 | FileSystem.layerNoop({ | |
| 232 | // The first scan parks inside `stat` so the interrupt lands while | |
| 233 | // discovery is in flight, which is what a client disconnecting | |
| 234 | // mid-connect does to the shared effect. | |
| 235 | stat: () => | |
| 236 | Effect.gen(function* () { | |
| 237 | scans += 1; | |
| 238 | if (blockFirstScan) { | |
| 239 | return yield* Effect.never; | |
| 240 | } | |
| 241 | return fileInfo; | |
| 242 | }), | |
| 243 | }), | |
| 244 | Path.layer, | |
| 245 | Layer.succeed( | |
| 246 | ChildProcessSpawner.ChildProcessSpawner, | |
| 247 | ChildProcessSpawner.make(() => Effect.sync(() => makeMockDetachedHandle())), | |
| 248 | ), | |
| 249 | ), | |
| 250 | ), | |
| 251 | ); | |
| 252 | ||
| 253 | return Effect.gen(function* () { | |
| 254 | const launcher = yield* ExternalLauncher.ExternalLauncher; | |
| 255 | ||
| 256 | const fiber = yield* Effect.forkChild(launcher.resolveAvailableEditors()); | |
| 257 | yield* Effect.yieldNow; | |
| 258 | yield* Fiber.interrupt(fiber); | |
| 259 | ||
| 260 | // The next connect must still get a real answer well inside the TTL. | |
| 261 | blockFirstScan = false; | |
| 262 | scans = 0; | |
| 263 | const editors = yield* launcher.resolveAvailableEditors(); | |
| 264 | assert.equal(editors.includes("vscode"), true); | |
| 265 | assert.isAbove(scans, 0); | |
| 266 | }).pipe( | |
| 267 | Effect.provide( | |
| 268 | Layer.mergeAll( | |
| 269 | launcherLayer, | |
| 270 | Layer.succeed(HostProcessPlatform, "win32"), | |
| 271 | ConfigProvider.layer( | |
| 272 | ConfigProvider.fromEnv({ | |
| 273 | env: { | |
| 274 | PATH: "C:\\t3-editor-discovery-interrupt-test", | |
| 275 | PATHEXT: ".COM;.EXE;.BAT;.CMD", | |
| 276 | }, | |
| 277 | }), | |
| 278 | ), | |
| 279 | ), | |
| 280 | ), | |
| 281 | ); | |
| 282 | }); | |
| 283 | ||
| 219 | 284 | it.effect("rejects unknown editors through the service API", () => |
| 220 | 285 | Effect.gen(function* () { |
| 221 | 286 | const launcher = yield* ExternalLauncher.ExternalLauncher; |
apps/server/src/process/externalLauncher.ts+37-4| ... | ... | @@ -19,6 +19,7 @@ import { |
| 19 | 19 | } from "@t3tools/contracts"; |
| 20 | 20 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 21 | 21 | import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell"; |
| 22 | import * as Clock from "effect/Clock"; | |
| 22 | 23 | import * as Config from "effect/Config"; |
| 23 | 24 | import * as Context from "effect/Context"; |
| 24 | 25 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -27,6 +28,7 @@ import * as FileSystem from "effect/FileSystem"; |
| 27 | 28 | import * as Layer from "effect/Layer"; |
| 28 | 29 | import * as Option from "effect/Option"; |
| 29 | 30 | import * as Path from "effect/Path"; |
| 31 | import * as Ref from "effect/Ref"; | |
| 30 | 32 | import * as ChildProcess from "effect/unstable/process/ChildProcess"; |
| 31 | 33 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; |
| 32 | 34 | |
| ... | ... | @@ -302,7 +304,23 @@ const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEdit |
| 302 | 304 | // client connect (the server config embeds the available editors). Memoize |
| 303 | 305 | // the discovered set for a bounded window so repeat connects skip even the |
| 304 | 306 | // per-command cache lookups in @t3tools/shared/shell. |
| 305 | const EDITOR_DISCOVERY_CACHE_TTL = "60 seconds"; | |
| 307 | // | |
| 308 | // This deliberately does not use `Effect.cachedWithTTL`: that memoizes the | |
| 309 | // first caller's Exit whatever it is, including an interrupt. Callers run this | |
| 310 | // on the connection fiber under a timeout (`resolveAvailableEditorsForConfig`), | |
| 311 | // so one client disconnecting mid-scan would cache the interrupt and replay it | |
| 312 | // to every later connect for the whole TTL, breaking `server.getConfig` | |
| 313 | // permanently. Storing only on success means an interrupted scan leaves the | |
| 314 | // cache untouched and the next connect simply rescans. | |
| 315 | // Expiry uses the monotonic clock (Clock.currentTimeNanos), matching the | |
| 316 | // command-resolution cache in @t3tools/shared/shell, so a backward wall-clock | |
| 317 | // adjustment cannot keep an expired entry alive. | |
| 318 | const EDITOR_DISCOVERY_CACHE_TTL_NANOS = 60_000_000_000n; | |
| 319 | ||
| 320 | interface EditorDiscoveryCacheEntry { | |
| 321 | readonly editors: ReadonlyArray<EditorId>; | |
| 322 | readonly expiresAtNanos: bigint; | |
| 323 | } | |
| 306 | 324 | |
| 307 | 325 | /** |
| 308 | 326 | * ExternalLauncher - Service tag for browser/editor launch operations. |
| ... | ... | @@ -449,10 +467,25 @@ export const make = Effect.gen(function* () { |
| 449 | 467 | Effect.provideService(Path.Path, path), |
| 450 | 468 | ); |
| 451 | 469 | |
| 452 | const cachedAvailableEditors = yield* Effect.cachedWithTTL( | |
| 453 | provideCommandResolutionServices(resolveAvailableEditors()), | |
| 454 | EDITOR_DISCOVERY_CACHE_TTL, | |
| 470 | const editorDiscoveryCache = yield* Ref.make<Option.Option<EditorDiscoveryCacheEntry>>( | |
| 471 | Option.none(), | |
| 455 | 472 | ); |
| 473 | const cachedAvailableEditors = Effect.gen(function* () { | |
| 474 | const nowNanos = yield* Clock.currentTimeNanos; | |
| 475 | const entry = yield* Ref.get(editorDiscoveryCache); | |
| 476 | if (Option.isSome(entry) && entry.value.expiresAtNanos > nowNanos) { | |
| 477 | return entry.value.editors; | |
| 478 | } | |
| 479 | const editors = yield* provideCommandResolutionServices(resolveAvailableEditors()); | |
| 480 | yield* Ref.set( | |
| 481 | editorDiscoveryCache, | |
| 482 | Option.some({ | |
| 483 | editors, | |
| 484 | expiresAtNanos: nowNanos + EDITOR_DISCOVERY_CACHE_TTL_NANOS, | |
| 485 | }), | |
| 486 | ); | |
| 487 | return editors; | |
| 488 | }); | |
| 456 | 489 | |
| 457 | 490 | return ExternalLauncher.of({ |
| 458 | 491 | resolveAvailableEditors: () => cachedAvailableEditors, |