diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 36ef8264328..1ab6166e92a 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -2,6 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; @@ -216,6 +217,70 @@ it.effect("memoizes editor discovery and refreshes after the cache window", () = ); }); +// A client that disconnects mid-scan interrupts the shared discovery effect on +// the connection fiber. The cache must not retain that interrupt: doing so +// replayed it to every later connect for the whole TTL, so `server.getConfig` +// failed and no client could reconnect until the server restarted. +it.effect("rescans after an interrupted discovery instead of caching the interrupt", () => { + const fileInfo = { type: "File" } as FileSystem.File.Info; + let blockFirstScan = true; + let scans = 0; + const launcherLayer = ExternalLauncher.layer.pipe( + Layer.provide( + Layer.mergeAll( + FileSystem.layerNoop({ + // The first scan parks inside `stat` so the interrupt lands while + // discovery is in flight, which is what a client disconnecting + // mid-connect does to the shared effect. + stat: () => + Effect.gen(function* () { + scans += 1; + if (blockFirstScan) { + return yield* Effect.never; + } + return fileInfo; + }), + }), + Path.layer, + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.sync(() => makeMockDetachedHandle())), + ), + ), + ), + ); + + return Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + + const fiber = yield* Effect.forkChild(launcher.resolveAvailableEditors()); + yield* Effect.yieldNow; + yield* Fiber.interrupt(fiber); + + // The next connect must still get a real answer well inside the TTL. + blockFirstScan = false; + scans = 0; + const editors = yield* launcher.resolveAvailableEditors(); + assert.equal(editors.includes("vscode"), true); + assert.isAbove(scans, 0); + }).pipe( + Effect.provide( + Layer.mergeAll( + launcherLayer, + Layer.succeed(HostProcessPlatform, "win32"), + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + PATH: "C:\\t3-editor-discovery-interrupt-test", + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }, + }), + ), + ), + ), + ); +}); + it.effect("rejects unknown editors through the service API", () => Effect.gen(function* () { const launcher = yield* ExternalLauncher.ExternalLauncher; diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 2cac42f0fec..8ec928f26fc 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -19,6 +19,7 @@ import { } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as Clock from "effect/Clock"; import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -27,6 +28,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; @@ -302,7 +304,23 @@ const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEdit // client connect (the server config embeds the available editors). Memoize // the discovered set for a bounded window so repeat connects skip even the // per-command cache lookups in @t3tools/shared/shell. -const EDITOR_DISCOVERY_CACHE_TTL = "60 seconds"; +// +// This deliberately does not use `Effect.cachedWithTTL`: that memoizes the +// first caller's Exit whatever it is, including an interrupt. Callers run this +// on the connection fiber under a timeout (`resolveAvailableEditorsForConfig`), +// so one client disconnecting mid-scan would cache the interrupt and replay it +// to every later connect for the whole TTL, breaking `server.getConfig` +// permanently. Storing only on success means an interrupted scan leaves the +// cache untouched and the next connect simply rescans. +// Expiry uses the monotonic clock (Clock.currentTimeNanos), matching the +// command-resolution cache in @t3tools/shared/shell, so a backward wall-clock +// adjustment cannot keep an expired entry alive. +const EDITOR_DISCOVERY_CACHE_TTL_NANOS = 60_000_000_000n; + +interface EditorDiscoveryCacheEntry { + readonly editors: ReadonlyArray; + readonly expiresAtNanos: bigint; +} /** * ExternalLauncher - Service tag for browser/editor launch operations. @@ -449,10 +467,25 @@ export const make = Effect.gen(function* () { Effect.provideService(Path.Path, path), ); - const cachedAvailableEditors = yield* Effect.cachedWithTTL( - provideCommandResolutionServices(resolveAvailableEditors()), - EDITOR_DISCOVERY_CACHE_TTL, + const editorDiscoveryCache = yield* Ref.make>( + Option.none(), ); + const cachedAvailableEditors = Effect.gen(function* () { + const nowNanos = yield* Clock.currentTimeNanos; + const entry = yield* Ref.get(editorDiscoveryCache); + if (Option.isSome(entry) && entry.value.expiresAtNanos > nowNanos) { + return entry.value.editors; + } + const editors = yield* provideCommandResolutionServices(resolveAvailableEditors()); + yield* Ref.set( + editorDiscoveryCache, + Option.some({ + editors, + expiresAtNanos: nowNanos + EDITOR_DISCOVERY_CACHE_TTL_NANOS, + }), + ); + return editors; + }); return ExternalLauncher.of({ resolveAvailableEditors: () => cachedAvailableEditors,