diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 43ca40e9c7c..f2e646af8f2 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"; @@ -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; diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 9c2f0e417d3..f7752ebcd47 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -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"; @@ -264,24 +265,16 @@ const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors" platform: NodeJS.Platform, env: NodeJS.ProcessEnv, ): Effect.fn.Return, 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* ( @@ -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),