authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-07 02:15:40-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-06 23:15:40-07:00
log9547cf24634ccafe5d381964449b722d33f812b8
treec4ab6876aa34c074638cbe1919d9f060e6cd4497
parent1c7d059f550a53dd94d5b9802640ecd11b759d1a
signature Signed by PGP key B5690EEEBB952194

fix(server): one disconnecting client no longer blocks every reconnect (#5572)

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";
22import { assert, it } from "@effect/vitest";
33import * as ConfigProvider from "effect/ConfigProvider";
44import * as Effect from "effect/Effect";
5import * as Fiber from "effect/Fiber";
56import * as FileSystem from "effect/FileSystem";
67import * as Layer from "effect/Layer";
78import * as Path from "effect/Path";
......@@ -216,6 +217,70 @@ it.effect("memoizes editor discovery and refreshes after the cache window", () =
216217 );
217218});
218219
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.
224it.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
219284it.effect("rejects unknown editors through the service API", () =>
220285 Effect.gen(function* () {
221286 const launcher = yield* ExternalLauncher.ExternalLauncher;
apps/server/src/process/externalLauncher.ts+37-4
......@@ -19,6 +19,7 @@ import {
1919} from "@t3tools/contracts";
2020import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
2121import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell";
22import * as Clock from "effect/Clock";
2223import * as Config from "effect/Config";
2324import * as Context from "effect/Context";
2425import * as Effect from "effect/Effect";
......@@ -27,6 +28,7 @@ import * as FileSystem from "effect/FileSystem";
2728import * as Layer from "effect/Layer";
2829import * as Option from "effect/Option";
2930import * as Path from "effect/Path";
31import * as Ref from "effect/Ref";
3032import * as ChildProcess from "effect/unstable/process/ChildProcess";
3133import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
3234
......@@ -302,7 +304,23 @@ const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEdit
302304// client connect (the server config embeds the available editors). Memoize
303305// the discovered set for a bounded window so repeat connects skip even the
304306// per-command cache lookups in @t3tools/shared/shell.
305const 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.
318const EDITOR_DISCOVERY_CACHE_TTL_NANOS = 60_000_000_000n;
319
320interface EditorDiscoveryCacheEntry {
321 readonly editors: ReadonlyArray<EditorId>;
322 readonly expiresAtNanos: bigint;
323}
306324
307325/**
308326 * ExternalLauncher - Service tag for browser/editor launch operations.
......@@ -449,10 +467,25 @@ export const make = Effect.gen(function* () {
449467 Effect.provideService(Path.Path, path),
450468 );
451469
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(),
455472 );
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 });
456489
457490 return ExternalLauncher.of({
458491 resolveAvailableEditors: () => cachedAvailableEditors,