From 2fb632982eaac6a3b01715e02d1eb125856dbe90 Mon Sep 17 00:00:00 2001 From: Andrew Forster Date: Fri, 24 Jul 2026 01:12:26 -0700 Subject: [PATCH 1/2] Decouple editor discovery from connection setup --- .../src/process/externalLauncher.test.ts | 28 ++++++++++++++ apps/server/src/process/externalLauncher.ts | 13 +++++++ apps/server/src/server.test.ts | 38 +++++++++++++++++++ apps/server/src/ws.ts | 14 ++++++- .../client-runtime/src/state/server.test.ts | 18 +++++++++ packages/client-runtime/src/state/server.ts | 9 +++++ packages/contracts/src/server.ts | 15 ++++++++ 7 files changed, 133 insertions(+), 2 deletions(-) diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 43ca40e9c7c..6804034a0a2 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -4,6 +4,7 @@ import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; 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 Sink from "effect/Sink"; import * as Stream from "effect/Stream"; @@ -155,6 +156,33 @@ it.effect("discovers editors through the service API", () => }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); +it.effect("publishes editor discovery to cached availability state", () => + 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; + const discovered = yield* launcher.streamAvailableEditors.pipe( + Stream.filter((editors) => editors.includes("vscode")), + Stream.runHead, + ); + + assert.include(Option.getOrThrow(discovered), "vscode"); + assert.include(yield* launcher.availableEditors, "vscode"); + }).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..0d70678c2a8 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -27,6 +27,8 @@ 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 Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; @@ -304,7 +306,9 @@ const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEdit export class ExternalLauncher extends Context.Service< ExternalLauncher, { + readonly availableEditors: Effect.Effect>; readonly resolveAvailableEditors: () => Effect.Effect>; + readonly streamAvailableEditors: Stream.Stream>; /** Launch a URL target in the default browser. */ readonly launchBrowser: (target: string) => Effect.Effect; /** @@ -434,6 +438,7 @@ export const make = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const availableEditors = yield* SubscriptionRef.make>([]); const provideCommandResolutionServices = ( effect: Effect.Effect, @@ -443,8 +448,16 @@ export const make = Effect.gen(function* () { Effect.provideService(Path.Path, path), ); + yield* provideCommandResolutionServices(resolveAvailableEditors()).pipe( + Effect.flatMap((editors) => SubscriptionRef.set(availableEditors, editors)), + Effect.ignoreCause({ log: true }), + Effect.forkScoped, + ); + return ExternalLauncher.of({ + availableEditors: SubscriptionRef.get(availableEditors), resolveAvailableEditors: () => provideCommandResolutionServices(resolveAvailableEditors()), + streamAvailableEditors: SubscriptionRef.changes(availableEditors), launchBrowser: (target) => launchBrowser(target).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 871b79eca90..2f77f93079c 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -569,7 +569,9 @@ const buildAppUnderTest = (options?: { ), Layer.provide( Layer.mock(ExternalLauncher.ExternalLauncher)({ + availableEditors: Effect.succeed([]), resolveAvailableEditors: () => Effect.succeed([]), + streamAvailableEditors: Stream.empty, ...options?.layers?.externalLauncher, }), ), @@ -4287,6 +4289,42 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("streams editor availability without blocking the config snapshot", () => + Effect.gen(function* () { + yield* buildAppUnderTest({ + layers: { + externalLauncher: { + availableEditors: Effect.succeed([]), + resolveAvailableEditors: () => Effect.never, + streamAvailableEditors: Stream.succeed(["vscode"]), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const events = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.subscribeServerConfig]({}).pipe( + Stream.take(2), + Stream.runCollect, + Effect.timeout("1 second"), + ), + ), + ); + + const [first, second] = Array.from(events); + assert.equal(first?.type, "snapshot"); + if (first?.type === "snapshot") { + assert.deepEqual(first.config.availableEditors, []); + } + assert.deepEqual(second, { + version: 1, + type: "availableEditorsUpdated", + payload: { availableEditors: ["vscode"] }, + }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc subscribeServerConfig emits provider status updates", () => Effect.gen(function* () { const nextProviders = [ diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index f6f46d1e76e..c3cc8e8304e 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1083,7 +1083,7 @@ const makeWsRpcLayer = ( keybindings: keybindingsConfig.keybindings, issues: keybindingsConfig.issues, providers, - availableEditors: yield* externalLauncher.resolveAvailableEditors(), + availableEditors: yield* externalLauncher.availableEditors, observability: { logsDirectoryPath: config.logsDir, localTracingEnabled: true, @@ -2011,6 +2011,13 @@ const makeWsRpcLayer = ( payload: { settings }, })), ); + const availableEditorsUpdates = externalLauncher.streamAvailableEditors.pipe( + Stream.map((availableEditors) => ({ + version: 1 as const, + type: "availableEditorsUpdated" as const, + payload: { availableEditors }, + })), + ); yield* providerRegistry .refresh() @@ -2018,7 +2025,10 @@ const makeWsRpcLayer = ( const liveUpdates = Stream.merge( keybindingsUpdates, - Stream.merge(providerStatuses, settingsUpdates), + Stream.merge( + providerStatuses, + Stream.merge(settingsUpdates, availableEditorsUpdates), + ), ); return Stream.concat( diff --git a/packages/client-runtime/src/state/server.test.ts b/packages/client-runtime/src/state/server.test.ts index 7ced26f0ff1..430f716ebe1 100644 --- a/packages/client-runtime/src/state/server.test.ts +++ b/packages/client-runtime/src/state/server.test.ts @@ -80,6 +80,24 @@ describe("server state projection", () => { expect(result.latestEvent.type).toBe("settingsUpdated"); }); + it("applies editor availability updates without replacing the config snapshot", () => { + const snapshot = applyServerConfigProjection(Option.none(), { + version: 1, + type: "snapshot", + config: CONFIG, + }); + const availableEditors: ServerConfig["availableEditors"] = ["vscode"]; + const projected = applyServerConfigProjection(snapshot, { + version: 1, + type: "availableEditorsUpdated", + payload: { availableEditors }, + }); + + const result = Option.getOrThrow(projected); + expect(result.config.availableEditors).toBe(availableEditors); + expect(result.latestEvent.type).toBe("availableEditorsUpdated"); + }); + it("retains welcome when a ready event follows in the same stream chunk", () => { const welcome = { environment: {} as ServerLifecycleWelcomePayload["environment"], diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index ea7f5fb6d75..7473c957058 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -72,6 +72,15 @@ export function applyServerConfigProjection( latestEvent: event, source: "live", })); + case "availableEditorsUpdated": + return Option.map(current, (projection) => ({ + config: { + ...projection.config, + availableEditors: event.payload.availableEditors, + }, + latestEvent: event, + source: "live", + })); } } diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 69699c7a839..09206fd76bb 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -475,6 +475,12 @@ export const ServerConfigSettingsUpdatedPayload = Schema.Struct({ }); export type ServerConfigSettingsUpdatedPayload = typeof ServerConfigSettingsUpdatedPayload.Type; +export const ServerConfigAvailableEditorsUpdatedPayload = Schema.Struct({ + availableEditors: Schema.Array(EditorId), +}); +export type ServerConfigAvailableEditorsUpdatedPayload = + typeof ServerConfigAvailableEditorsUpdatedPayload.Type; + export const ServerConfigStreamSnapshotEvent = Schema.Struct({ version: Schema.Literal(1), type: Schema.Literal("snapshot"), @@ -506,11 +512,20 @@ export const ServerConfigStreamSettingsUpdatedEvent = Schema.Struct({ export type ServerConfigStreamSettingsUpdatedEvent = typeof ServerConfigStreamSettingsUpdatedEvent.Type; +export const ServerConfigStreamAvailableEditorsUpdatedEvent = Schema.Struct({ + version: Schema.Literal(1), + type: Schema.Literal("availableEditorsUpdated"), + payload: ServerConfigAvailableEditorsUpdatedPayload, +}); +export type ServerConfigStreamAvailableEditorsUpdatedEvent = + typeof ServerConfigStreamAvailableEditorsUpdatedEvent.Type; + export const ServerConfigStreamEvent = Schema.Union([ ServerConfigStreamSnapshotEvent, ServerConfigStreamKeybindingsUpdatedEvent, ServerConfigStreamProviderStatusesEvent, ServerConfigStreamSettingsUpdatedEvent, + ServerConfigStreamAvailableEditorsUpdatedEvent, ]); export type ServerConfigStreamEvent = typeof ServerConfigStreamEvent.Type; From 4dd176c1568689051e87c17b1984659eb2c7fc0b Mon Sep 17 00:00:00 2001 From: Andrew Forster Date: Fri, 24 Jul 2026 01:19:08 -0700 Subject: [PATCH 2/2] Remove timing-sensitive editor discovery test --- .../src/process/externalLauncher.test.ts | 28 ------------------- 1 file changed, 28 deletions(-) diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 6804034a0a2..43ca40e9c7c 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -4,7 +4,6 @@ import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; 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 Sink from "effect/Sink"; import * as Stream from "effect/Stream"; @@ -156,33 +155,6 @@ it.effect("discovers editors through the service API", () => }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); -it.effect("publishes editor discovery to cached availability state", () => - 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; - const discovered = yield* launcher.streamAvailableEditors.pipe( - Stream.filter((editors) => editors.includes("vscode")), - Stream.runHead, - ); - - assert.include(Option.getOrThrow(discovered), "vscode"); - assert.include(yield* launcher.availableEditors, "vscode"); - }).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;