From e581f6efef9fef63b297baf2cbdbe324baf602ca Mon Sep 17 00:00:00 2001 From: Wout Stiens Date: Tue, 21 Jul 2026 08:35:36 +0200 Subject: [PATCH 1/3] fix(server): bound editor discovery latency --- .../src/process/externalLauncher.test.ts | 55 ++++++++++++- apps/server/src/process/externalLauncher.ts | 78 +++++++++++++------ 2 files changed, 110 insertions(+), 23 deletions(-) diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 43ca40e9c7c..0c0ef430c04 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -1,12 +1,15 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; @@ -38,6 +41,11 @@ const testLayer = (input: { readonly resolveExecutable?: (command: string) => string | undefined; readonly onSpawn?: (command: ChildProcess.StandardCommand) => void; readonly onUnref?: () => void; + readonly editorCommandAvailability?: ( + command: string, + env: NodeJS.ProcessEnv, + ) => Effect.Effect; + readonly editorDiscoveryTimeout?: Duration.Input; }) => { const spawnerLayer = Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, @@ -53,8 +61,20 @@ const testLayer = (input: { ), ); + const externalLauncherLayer = Layer.effect( + ExternalLauncher.ExternalLauncher, + ExternalLauncher.makeWithOptions({ + ...(input.editorCommandAvailability === undefined + ? {} + : { editorCommandAvailability: input.editorCommandAvailability }), + ...(input.editorDiscoveryTimeout === undefined + ? {} + : { editorDiscoveryTimeout: input.editorDiscoveryTimeout }), + }), + ); + return Layer.mergeAll( - ExternalLauncher.layer.pipe(Layer.provide(Layer.merge(NodeServices.layer, spawnerLayer))), + externalLauncherLayer.pipe(Layer.provide(Layer.merge(NodeServices.layer, spawnerLayer))), Layer.succeed(HostProcessPlatform, input.platform), Layer.succeed( SpawnExecutableResolution, @@ -155,6 +175,39 @@ it.effect("discovers editors through the service API", () => }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); +it.effect("bounds slow editor probes concurrently and keeps partial results", () => { + const discoveryTimeout = Duration.seconds(3); + + return Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + const discoveryFiber = yield* launcher.resolveAvailableEditors().pipe(Effect.forkScoped); + + yield* Effect.yieldNow; + yield* TestClock.adjust(discoveryTimeout); + + const editors = yield* Fiber.join(discoveryFiber); + assert.deepEqual(editors, ["cursor", "vscode"]); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.merge( + TestClock.layer(), + testLayer({ + platform: "win32", + env: { PATH: "C:\\bin", PATHEXT: ".COM;.EXE;.BAT;.CMD" }, + editorDiscoveryTimeout: discoveryTimeout, + editorCommandAvailability: (command) => { + if (command === "cursor") { + return Effect.sleep(Duration.seconds(1)).pipe(Effect.as(true)); + } + return command === "code" ? Effect.succeed(true) : Effect.never; + }, + }), + ), + ), + ); +}); + 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..eeedf8e8ebe 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -21,6 +21,7 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell"; import * as Config from "effect/Config"; import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; import * as FileSystem from "effect/FileSystem"; @@ -79,6 +80,18 @@ const DETACHED_IGNORE_STDIO_OPTIONS = { stderr: "ignore", } as const satisfies ChildProcess.CommandOptions; +const EDITOR_DISCOVERY_TIMEOUT = Duration.seconds(3); + +type EditorCommandAvailability = ( + command: string, + env: NodeJS.ProcessEnv, +) => Effect.Effect; + +interface ExternalLauncherOptions { + readonly editorCommandAvailability?: EditorCommandAvailability; + readonly editorDiscoveryTimeout?: Duration.Input; +} + const compactEnv = (input: Record>): NodeJS.ProcessEnv => Object.fromEntries( Object.entries(input).flatMap(([key, value]) => @@ -263,25 +276,32 @@ function buildBrowserLaunch( const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors")(function* ( 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); - } - } + commandAvailability: EditorCommandAvailability, + timeout: Duration.Input, +): Effect.fn.Return> { + const results = yield* Effect.forEach( + EDITORS, + (editor) => { + const probe = + editor.commands === null + ? commandAvailability(fileManagerCommandForPlatform(platform), env) + : Effect.gen(function* () { + for (const command of editor.commands) { + if (yield* commandAvailability(command, env)) return true; + } + return false; + }); + + return probe.pipe( + Effect.timeoutOption(timeout), + Effect.map(Option.getOrElse(() => false)), + Effect.map((available) => (available ? Option.some(editor.id) : Option.none())), + ); + }, + { concurrency: "unbounded" }, + ); - return available; + return results.flatMap(Option.toArray); }); const resolveBrowserLaunch = Effect.fn("externalLauncher.resolveBrowserLaunch")(function* ( @@ -292,10 +312,13 @@ const resolveBrowserLaunch = Effect.fn("externalLauncher.resolveBrowserLaunch")( return buildBrowserLaunch(target, platform, env); }); -const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEditors")(function* () { +const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEditors")(function* ( + commandAvailability: EditorCommandAvailability, + timeout: Duration.Input, +) { const platform = yield* HostProcessPlatform; const env = yield* readCommandLookupEnv; - return yield* buildAvailableEditors(platform, env); + return yield* buildAvailableEditors(platform, env, commandAvailability, timeout); }); /** @@ -430,7 +453,9 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu ); }); -export const make = Effect.gen(function* () { +export const makeWithOptions = Effect.fn("ExternalLauncher.makeWithOptions")(function* ( + options: ExternalLauncherOptions = {}, +) { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -443,8 +468,15 @@ export const make = Effect.gen(function* () { Effect.provideService(Path.Path, path), ); + const editorCommandAvailability = + options.editorCommandAvailability ?? + ((command: string, env: NodeJS.ProcessEnv) => + provideCommandResolutionServices(isCommandAvailable(command, { env }))); + const editorDiscoveryTimeout = options.editorDiscoveryTimeout ?? EDITOR_DISCOVERY_TIMEOUT; + return ExternalLauncher.of({ - resolveAvailableEditors: () => provideCommandResolutionServices(resolveAvailableEditors()), + resolveAvailableEditors: () => + resolveAvailableEditors(editorCommandAvailability, editorDiscoveryTimeout), launchBrowser: (target) => launchBrowser(target).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), @@ -460,4 +492,6 @@ export const make = Effect.gen(function* () { }); }); +export const make = makeWithOptions(); + export const layer = Layer.effect(ExternalLauncher, make); From fe6834f3663c45c8688c60e7d16a970d11f07a19 Mon Sep 17 00:00:00 2001 From: Wout Stiens Date: Tue, 21 Jul 2026 08:52:48 +0200 Subject: [PATCH 2/3] fix(server): probe editor aliases concurrently --- apps/server/src/process/externalLauncher.test.ts | 6 ++++-- apps/server/src/process/externalLauncher.ts | 13 +++++++------ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 0c0ef430c04..849a9c38078 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -186,7 +186,7 @@ it.effect("bounds slow editor probes concurrently and keeps partial results", () yield* TestClock.adjust(discoveryTimeout); const editors = yield* Fiber.join(discoveryFiber); - assert.deepEqual(editors, ["cursor", "vscode"]); + assert.deepEqual(editors, ["cursor", "vscode", "zed"]); }).pipe( Effect.scoped, Effect.provide( @@ -200,7 +200,9 @@ it.effect("bounds slow editor probes concurrently and keeps partial results", () if (command === "cursor") { return Effect.sleep(Duration.seconds(1)).pipe(Effect.as(true)); } - return command === "code" ? Effect.succeed(true) : Effect.never; + return command === "code" || command === "zeditor" + ? Effect.succeed(true) + : Effect.never; }, }), ), diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index eeedf8e8ebe..75012c69119 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -285,12 +285,13 @@ const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors" const probe = editor.commands === null ? commandAvailability(fileManagerCommandForPlatform(platform), env) - : Effect.gen(function* () { - for (const command of editor.commands) { - if (yield* commandAvailability(command, env)) return true; - } - return false; - }); + : Effect.raceAll( + editor.commands.map((command) => + commandAvailability(command, env).pipe( + Effect.filterOrFail((available) => available), + ), + ), + ).pipe(Effect.orElseSucceed(() => false)); return probe.pipe( Effect.timeoutOption(timeout), From 0ce82e3f6dd06c62d38bfebdff6156e4814cf950 Mon Sep 17 00:00:00 2001 From: Wout Stiens Date: Tue, 21 Jul 2026 09:09:14 +0200 Subject: [PATCH 3/3] fix(server): align editor alias launching --- .../src/process/externalLauncher.test.ts | 25 ++++++++++++++++ apps/server/src/process/externalLauncher.ts | 29 ++++++++++++------- 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 849a9c38078..e1db16aa618 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -150,6 +150,31 @@ it.effect("launches an installed editor with platform-safe arguments", () => }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); +it.effect("launches an editor through a responsive fallback alias", () => { + let spawned: ChildProcess.StandardCommand | undefined; + + return Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + yield* launcher.launchEditor({ editor: "zed", cwd: "/workspace" }); + + assert.ok(spawned); + assert.equal(spawned.command, "zeditor"); + assert.deepEqual(spawned.args, ["/workspace"]); + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { PATH: "/bin" }, + editorCommandAvailability: (command) => + command === "zeditor" ? Effect.succeed(true) : Effect.never, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); +}); + it.effect("discovers editors through the service API", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 75012c69119..729895131cb 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -173,13 +173,20 @@ function resolveEditorArgs( const resolveAvailableCommand = Effect.fn("externalLauncher.resolveAvailableCommand")(function* ( commands: ReadonlyArray, env: NodeJS.ProcessEnv, -): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { - for (const command of commands) { - if (yield* isCommandAvailable(command, { env })) { - return Option.some(command); - } + commandAvailability: EditorCommandAvailability, +): Effect.fn.Return> { + if (commands.length === 0) { + return Option.none(); } - return Option.none(); + + return yield* Effect.raceAll( + commands.map((command) => + commandAvailability(command, env).pipe( + Effect.filterOrFail((available) => available), + Effect.as(command), + ), + ), + ).pipe(Effect.option); }); function encodeUtf16LeBase64(input: string): string { @@ -346,6 +353,7 @@ export class ExternalLauncher extends Context.Service< const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( input: LaunchEditorInput, + commandAvailability: EditorCommandAvailability, ): Effect.fn.Return { const platform = yield* HostProcessPlatform; const env = yield* readCommandLookupEnv; @@ -361,7 +369,7 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( if (editorDef.commands) { const command = Option.getOrElse( - yield* resolveAvailableCommand(editorDef.commands, env), + yield* resolveAvailableCommand(editorDef.commands, env, commandAvailability), () => editorDef.commands[0], ); return { @@ -417,13 +425,14 @@ const launchBrowser = Effect.fn("externalLauncher.launchBrowser")(function* ( const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(function* ( launch: EditorLaunch, + commandAvailability: EditorCommandAvailability, ): Effect.fn.Return< void, ExternalLauncherError, ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path > { const env = yield* readCommandLookupEnv; - if (!(yield* isCommandAvailable(launch.command, { env }))) { + if (!(yield* commandAvailability(launch.command, env))) { return yield* new ExternalLauncherCommandNotFoundError({ editor: launch.editor, command: launch.command, @@ -484,8 +493,8 @@ export const makeWithOptions = Effect.fn("ExternalLauncher.makeWithOptions")(fun ), launchEditor: (input) => provideCommandResolutionServices( - Effect.flatMap(resolveEditorLaunch(input), (launch) => - launchEditorProcess(launch).pipe( + Effect.flatMap(resolveEditorLaunch(input, editorCommandAvailability), (launch) => + launchEditorProcess(launch, editorCommandAvailability).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ), ),