Skip to content
Open
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
60 changes: 60 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 @@ -155,6 +156,65 @@ it.effect("discovers editors through the service API", () =>
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("caches editor discovery per service instance", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" });
const codePath = path.join(binDir, "code.CMD");
yield* fileSystem.writeFileString(codePath, "@echo off\r\n");

yield* Effect.gen(function* () {
const launcher = yield* ExternalLauncher.ExternalLauncher;

const first = yield* launcher.resolveAvailableEditors();
assert.equal(first.includes("vscode"), true);

// A fresh probe would no longer find the command; the cached result must.
yield* fileSystem.remove(codePath);
const second = yield* launcher.resolveAvailableEditors();
assert.deepEqual(second, first);
}).pipe(
Effect.provide(
testLayer({
platform: "win32",
env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD" },
}),
),
);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("keeps discovery usable after a caller gives up", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" });
yield* fileSystem.writeFileString(path.join(binDir, "code.CMD"), "@echo off\r\n");

yield* Effect.gen(function* () {
const launcher = yield* ExternalLauncher.ExternalLauncher;

// Mirrors ws.ts timing discovery out: the first caller is interrupted
// mid-scan. That must not memoize the interruption for later callers.
const abandoned = yield* Effect.forkChild(launcher.resolveAvailableEditors(), {
startImmediately: true,
});
yield* Fiber.interrupt(abandoned);

const editors = yield* launcher.resolveAvailableEditors();
assert.equal(editors.includes("vscode"), true);
}).pipe(
Effect.provide(
testLayer({
platform: "win32",
env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD" },
}),
),
);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("rejects unknown editors through the service API", () =>
Effect.gen(function* () {
const launcher = yield* ExternalLauncher.ExternalLauncher;
Expand Down
39 changes: 21 additions & 18 deletions apps/server/src/process/externalLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import * as Config from "effect/Config";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Encoding from "effect/Encoding";
import * as Fiber from "effect/Fiber";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
Expand Down Expand Up @@ -264,24 +265,16 @@ const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors"
platform: NodeJS.Platform,
env: NodeJS.ProcessEnv,
): Effect.fn.Return<ReadonlyArray<EditorId>, never, FileSystem.FileSystem | Path.Path> {
const available: EditorId[] = [];

for (const editor of EDITORS) {
if (editor.commands === null) {
const command = fileManagerCommandForPlatform(platform);
if (yield* isCommandAvailable(command, { env })) {
available.push(editor.id);
}
continue;
}

const command = yield* resolveAvailableCommand(editor.commands, env);
if (Option.isSome(command)) {
available.push(editor.id);
}
}
const availability = yield* Effect.forEach(
EDITORS,
(editor) =>
editor.commands === null
? isCommandAvailable(fileManagerCommandForPlatform(platform), { env })
: Effect.map(resolveAvailableCommand(editor.commands, env), Option.isSome),
{ concurrency: EDITORS.length },
);

return available;
return EDITORS.filter((_, index) => availability[index]).map((editor) => editor.id);
});

const resolveBrowserLaunch = Effect.fn("externalLauncher.resolveBrowserLaunch")(function* (
Expand Down Expand Up @@ -443,8 +436,18 @@ export const make = Effect.gen(function* () {
Effect.provideService(Path.Path, path),
);

// Discovery probes every editor command across PATH, which can take seconds on
// hosts with long PATHs. Run it once in a detached fiber and let callers join:
// a caller that gives up (ws.ts times discovery out) then cancels only its own
// wait, leaving the scan to finish and serve everyone who asks afterwards.
const editorDiscovery = yield* Effect.cached(
Effect.uninterruptible(
Effect.forkDetach(provideCommandResolutionServices(resolveAvailableEditors())),
),
);

return ExternalLauncher.of({
resolveAvailableEditors: () => provideCommandResolutionServices(resolveAvailableEditors()),
resolveAvailableEditors: () => Effect.flatMap(editorDiscovery, Fiber.join),
launchBrowser: (target) =>
launchBrowser(target).pipe(
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
Expand Down
Loading