Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions apps/server/src/process/externalLauncher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down
41 changes: 37 additions & 4 deletions apps/server/src/process/externalLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";

Expand Down Expand Up @@ -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<EditorId>;
readonly expiresAtNanos: bigint;
}

/**
* ExternalLauncher - Service tag for browser/editor launch operations.
Expand Down Expand Up @@ -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.Option<EditorDiscoveryCacheEntry>>(
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,
Expand Down
Loading