diff --git a/apps/server/src/environment/RemoteOpenTargets.ts b/apps/server/src/environment/RemoteOpenTargets.ts index e0acfaee2f48..e11d565be1c4 100644 --- a/apps/server/src/environment/RemoteOpenTargets.ts +++ b/apps/server/src/environment/RemoteOpenTargets.ts @@ -17,7 +17,10 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import { makeStaleWhileRevalidate } from "./staleWhileRevalidate.ts"; + const SSH_PORT = 22; +const TARGETS_CACHE_TTL = "60 seconds"; export class RemoteOpenTargets extends Context.Service< RemoteOpenTargets, @@ -31,7 +34,7 @@ export const make = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const net = yield* NetService.NetService; - const resolveTargets = Effect.gen(function* () { + const discoverTargets = Effect.gen(function* () { // No local sshd means no name can work; advertise nothing so clients // render a clear "no SSH route" state instead of links that hang. // Check both loopback families: sshd can be bound IPv6-only. @@ -67,6 +70,10 @@ export const make = Effect.gen(function* () { return targets; }); + // Advertised on every client connect; probing sshd and tailscaled is slow + // on a loaded host, so serve the last result while it refreshes. + const resolveTargets = yield* makeStaleWhileRevalidate(discoverTargets, TARGETS_CACHE_TTL); + return RemoteOpenTargets.of({ resolveTargets: () => resolveTargets }); }); diff --git a/apps/server/src/environment/staleWhileRevalidate.test.ts b/apps/server/src/environment/staleWhileRevalidate.test.ts new file mode 100644 index 000000000000..7aca66bec99d --- /dev/null +++ b/apps/server/src/environment/staleWhileRevalidate.test.ts @@ -0,0 +1,140 @@ +import { assert, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Ref from "effect/Ref"; +import * as Scope from "effect/Scope"; +import * as TestClock from "effect/testing/TestClock"; + +import { makeStaleWhileRevalidate } from "./staleWhileRevalidate.ts"; + +it.effect("computes once, then answers from memory until the ttl lapses", () => + Effect.gen(function* () { + const calls = yield* Ref.make(0); + const read = yield* makeStaleWhileRevalidate( + Ref.updateAndGet(calls, (count) => count + 1), + "60 seconds", + ); + + assert.equal(yield* read, 1); + yield* TestClock.adjust("59 seconds"); + assert.equal(yield* read, 1); + assert.equal(yield* Ref.get(calls), 1); + }).pipe(Effect.scoped), +); + +it.effect("serves the stale value while one background refresh replaces it", () => + Effect.gen(function* () { + const calls = yield* Ref.make(0); + const release = yield* Deferred.make(); + const read = yield* makeStaleWhileRevalidate( + Effect.gen(function* () { + const call = yield* Ref.updateAndGet(calls, (count) => count + 1); + if (call > 1) yield* Deferred.await(release); + return call; + }), + "60 seconds", + ); + + assert.equal(yield* read, 1); + yield* TestClock.adjust("61 seconds"); + // The refresh is blocked, yet callers are answered immediately and only + // one refresh starts however many callers arrive. + assert.equal(yield* read, 1); + assert.equal(yield* read, 1); + yield* TestClock.adjust("1 second"); + assert.equal(yield* Ref.get(calls), 2); + + yield* Deferred.succeed(release, undefined); + yield* TestClock.adjust("1 second"); + assert.equal(yield* read, 2); + }), +); + +it.effect("shares one first scan that an interrupted caller does not cancel", () => + Effect.gen(function* () { + const calls = yield* Ref.make(0); + const release = yield* Deferred.make(); + const read = yield* makeStaleWhileRevalidate( + Ref.updateAndGet(calls, (count) => count + 1).pipe(Effect.tap(() => Deferred.await(release))), + "60 seconds", + ); + + // A connect that times out or disconnects mid-scan leaves the scan running. + const interrupted = yield* read.pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* Fiber.interrupt(interrupted); + + const next = yield* read.pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* Deferred.succeed(release, undefined); + assert.equal(yield* Fiber.join(next), 1); + assert.equal(yield* Ref.get(calls), 1); + }).pipe(Effect.scoped), +); + +it.effect("starts over after a failed first scan", () => + Effect.gen(function* () { + const calls = yield* Ref.make(0); + const read = yield* makeStaleWhileRevalidate( + Ref.updateAndGet(calls, (count) => count + 1).pipe( + Effect.flatMap((call) => (call === 1 ? Effect.die("probe crashed") : Effect.succeed(call))), + ), + "60 seconds", + ); + + assert.isTrue(Exit.isFailure(yield* Effect.exit(read))); + assert.equal(yield* read, 2); + }).pipe(Effect.scoped), +); + +it.effect("abandons a hung refresh so a later call can start another", () => + Effect.gen(function* () { + const calls = yield* Ref.make(0); + const read = yield* makeStaleWhileRevalidate( + Ref.updateAndGet(calls, (count) => count + 1).pipe( + Effect.flatMap((call) => (call === 2 ? Effect.never : Effect.succeed(call))), + ), + "60 seconds", + ); + + assert.equal(yield* read, 1); + yield* TestClock.adjust("61 seconds"); + assert.equal(yield* read, 1); + yield* TestClock.adjust("1 second"); + assert.equal(yield* Ref.get(calls), 2); + + // The hung refresh times out, releasing the claim for the next caller. + yield* TestClock.adjust("30 seconds"); + assert.equal(yield* read, 1); + yield* TestClock.adjust("1 second"); + assert.equal(yield* Ref.get(calls), 3); + assert.equal(yield* read, 3); + }).pipe(Effect.scoped), +); + +it.effect("interrupts a background refresh when the owning scope closes", () => + Effect.gen(function* () { + const interrupted = yield* Deferred.make(); + const scope = yield* Scope.make(); + const calls = yield* Ref.make(0); + const read = yield* makeStaleWhileRevalidate( + Ref.updateAndGet(calls, (count) => count + 1).pipe( + Effect.flatMap((call) => + call === 1 + ? Effect.succeed(call) + : Effect.never.pipe(Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined))), + ), + ), + "60 seconds", + ).pipe(Scope.provide(scope)); + + yield* read; + yield* TestClock.adjust("61 seconds"); + yield* read; + yield* TestClock.adjust("1 second"); + yield* Scope.close(scope, Exit.void); + yield* Deferred.await(interrupted); + }), +); diff --git a/apps/server/src/environment/staleWhileRevalidate.ts b/apps/server/src/environment/staleWhileRevalidate.ts new file mode 100644 index 000000000000..958cadb4e3df --- /dev/null +++ b/apps/server/src/environment/staleWhileRevalidate.ts @@ -0,0 +1,100 @@ +import * as Clock from "effect/Clock"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Ref from "effect/Ref"; + +const REFRESH_TIMEOUT = Duration.seconds(30); + +type Entry = + | { readonly _tag: "Empty" } + | { readonly _tag: "Scanning"; readonly scan: Deferred.Deferred } + | { + readonly _tag: "Ready"; + readonly value: A; + readonly expiresAtNanos: bigint; + readonly refreshing: boolean; + }; + +/** + * Memoizes a discovery that is cheap when warm and slow on a loaded host, for + * callers that sit on the client connect path. Until a value exists, callers + * wait on one shared scan. After that every call returns the last good value + * immediately; once the value is older than `ttl`, one refresh replaces it in + * the background. + * + * Scans run on their own fiber in the scope that built the cache, never on a + * caller's. Callers time out and disconnect mid-connect, and neither may cancel + * a scan other callers are waiting on, or throw away work a slow host needs + * more than one connect to finish. Only successes are stored: a failed scan + * leaves the previous state, so the next caller starts over. Expiry uses the + * monotonic clock so a wall-clock adjustment cannot keep a stale entry alive. + * + * Closing the scope interrupts any scan (and cleans up a scoped probe process). + * A refresh is capped at `REFRESH_TIMEOUT` so a probe hung on an unresponsive + * mount cannot leave the entry stale forever; a first scan is not, because + * callers that already timed out still pick up its result later. + */ +export const makeStaleWhileRevalidate = (discover: Effect.Effect, ttl: Duration.Input) => { + const ttlNanos = Duration.toNanosUnsafe(Duration.fromInputUnsafe(ttl)); + return Effect.gen(function* () { + const scope = yield* Effect.scope; + const state = yield* Ref.make>({ _tag: "Empty" }); + + const settle = (exit: Exit.Exit) => + Effect.gen(function* () { + const now = yield* Clock.monotonicTimeNanos; + yield* Ref.update(state, (entry): Entry => + Exit.isSuccess(exit) + ? { + _tag: "Ready", + value: exit.value, + expiresAtNanos: now + ttlNanos, + refreshing: false, + } + : entry._tag === "Ready" + ? { ...entry, refreshing: false } + : { _tag: "Empty" }, + ); + }); + const fork = (scan: Effect.Effect) => + scan.pipe(Effect.ignoreCause({ log: true }), Effect.interruptible, Effect.forkIn(scope)); + const firstScan = (scan: Deferred.Deferred) => + fork( + discover.pipe( + Effect.onExit((exit) => settle(exit).pipe(Effect.andThen(Deferred.done(scan, exit)))), + ), + ); + const refresh = fork( + discover.pipe(Effect.onExit(settle), Effect.timeoutOption(REFRESH_TIMEOUT)), + ); + + return Effect.gen(function* () { + const now = yield* Clock.monotonicTimeNanos; + // Claiming a scan and starting it are one uninterruptible step: a caller + // interrupted between them would leave a claimed scan that never runs. + const result = yield* Ref.modify( + state, + (entry): readonly [readonly [Effect.Effect, Effect.Effect], Entry] => { + switch (entry._tag) { + case "Empty": { + const scan = Deferred.makeUnsafe(); + return [[firstScan(scan), Deferred.await(scan)], { _tag: "Scanning", scan }]; + } + case "Scanning": + return [[Effect.void, Deferred.await(entry.scan)], entry]; + case "Ready": + return entry.refreshing || entry.expiresAtNanos > now + ? [[Effect.void, Effect.succeed(entry.value)], entry] + : [[refresh, Effect.succeed(entry.value)], { ...entry, refreshing: true }]; + } + }, + ).pipe( + Effect.flatMap(([start, result]) => Effect.as(start, result)), + Effect.uninterruptible, + ); + return yield* result; + }); + }); +}; diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 774346b96fbd..538d32cff47c 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -1257,65 +1257,71 @@ it.effect.skipIf(windowsHost)("ignores unusable app bundles and keeps PATH launc }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); -it.effect("memoizes editor discovery and refreshes after the cache window", () => { - let statCalls = 0; - const fileInfo = { type: "File" } as FileSystem.File.Info; - const layerLauncher = ExternalLauncher.layer.pipe( - Layer.provide( - Layer.mergeAll( - FileSystem.layerNoop({ - stat: () => - Effect.sync(() => { - statCalls += 1; - return fileInfo; - }), - }), - Path.layer, - Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => Effect.sync(() => makeMockDetachedHandle())), +it.effect( + "memoizes editor discovery and refreshes in the background after the cache window", + () => { + let statCalls = 0; + const fileInfo = { type: "File" } as FileSystem.File.Info; + const layerLauncher = ExternalLauncher.layer.pipe( + Layer.provide( + Layer.mergeAll( + FileSystem.layerNoop({ + stat: () => + Effect.sync(() => { + statCalls += 1; + return fileInfo; + }), + }), + Path.layer, + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.sync(() => makeMockDetachedHandle())), + ), ), ), - ), - ); + ); - return Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; + return Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; - const first = yield* launcher.resolveAvailableEditors(); - assert.equal(first.includes("vscode"), true); - const statCallsAfterFirstScan = statCalls; - assert.isAbove(statCallsAfterFirstScan, 0); - - // Past the shared command-resolution cache TTL (30s) but within the - // discovery cache window: the memoized set is reused without any scan. - yield* TestClock.adjust("31 seconds"); - const second = yield* launcher.resolveAvailableEditors(); - assert.deepEqual([...second], [...first]); - assert.equal(statCalls, statCallsAfterFirstScan); - - // Past the discovery cache window the next call rescans. - yield* TestClock.adjust("30 seconds"); - yield* launcher.resolveAvailableEditors(); - assert.isAbove(statCalls, statCallsAfterFirstScan); - }).pipe( - Effect.provide( - Layer.mergeAll( - layerLauncher, - Layer.succeed(HostProcessPlatform, "win32"), - ConfigProvider.layer( - ConfigProvider.fromEnv({ - env: { - PATH: "C:\\t3-editor-discovery-cache-test", - PATHEXT: ".COM;.EXE;.BAT;.CMD", - }, - }), + const first = yield* launcher.resolveAvailableEditors(); + assert.equal(first.includes("vscode"), true); + const statCallsAfterFirstScan = statCalls; + assert.isAbove(statCallsAfterFirstScan, 0); + + // Past the shared command-resolution cache TTL (30s) but within the + // discovery cache window: the memoized set is reused without any scan. + yield* TestClock.adjust("31 seconds"); + const second = yield* launcher.resolveAvailableEditors(); + assert.deepEqual([...second], [...first]); + assert.equal(statCalls, statCallsAfterFirstScan); + + // Past the discovery cache window the next call still answers from the + // memoized set and rescans in the background. + yield* TestClock.adjust("30 seconds"); + const third = yield* launcher.resolveAvailableEditors(); + assert.deepEqual([...third], [...first]); + yield* Effect.yieldNow; + assert.isAbove(statCalls, statCallsAfterFirstScan); + }).pipe( + Effect.provide( + Layer.mergeAll( + layerLauncher, + Layer.succeed(HostProcessPlatform, "win32"), + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + PATH: "C:\\t3-editor-discovery-cache-test", + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }, + }), + ), + TestClock.layer(), ), - TestClock.layer(), ), - ), - ); -}); + ); + }, +); // Connects run discovery under a timeout and may disconnect mid-scan. Neither // may cancel the scan: on a busy host every connect would time out partway diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 6215f8653ffc..4b2aad63c8fe 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -25,23 +25,20 @@ import { resolveSpawnCommand, withPathDirectoryListings, } from "@t3tools/shared/shell"; -import * as Clock from "effect/Clock"; import * as Config from "effect/Config"; import * as Context from "effect/Context"; -import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Base64 from "effect/encoding/Base64"; -import * as Exit from "effect/Exit"; 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 Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import { makeStaleWhileRevalidate } from "../environment/staleWhileRevalidate.ts"; + // ============================== // Definitions // ============================== @@ -460,27 +457,11 @@ const resolveFileManagerRevealKind = Effect.fn("externalLauncher.resolveFileMana }, ); -// Editor discovery walks PATH for every known editor and runs for every -// 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. -// -// The scan runs on its own fiber in the service scope, and every caller awaits -// that one scan. Callers apply a timeout (`resolveAvailableEditorsForConfig`) -// and disconnect mid-connect; neither may cancel a scan other connects are -// waiting on, or throw away work a slow host (a busy server at startup, a -// long PATH) needs more than one connect to finish. A failed scan clears the -// entry so the next caller starts over rather than replaying the failure. -// Expiry uses the monotonic clock (Clock.monotonicTimeNanos), 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 scan: Deferred.Deferred>; - /** Undefined while the scan is still running. */ - readonly expiresAtNanos: bigint | undefined; -} +// Editor and file-manager discovery walk PATH and spawn probes, and run for +// every client connect (the server config embeds both). Callers share one scan +// that their timeouts and disconnects cannot cancel, and once a scan has +// succeeded a connect never waits on a rescan: see makeStaleWhileRevalidate. +const EDITOR_DISCOVERY_CACHE_TTL = "60 seconds"; /** * ExternalLauncher - Service tag for browser/editor launch operations. @@ -763,65 +744,22 @@ export const make = Effect.gen(function* () { Effect.provideService(Path.Path, path), ); - const scope = yield* Scope.Scope; - const editorDiscoveryCache = yield* Ref.make>( - Option.none(), - ); - const runEditorDiscovery = (scan: Deferred.Deferred>) => + const cachedAvailableEditors = yield* makeStaleWhileRevalidate( provideCommandResolutionServices(resolveAvailableEditors()).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.onExit((exit) => - Effect.gen(function* () { - const expiresAtNanos = - (yield* Clock.monotonicTimeNanos) + EDITOR_DISCOVERY_CACHE_TTL_NANOS; - yield* Ref.update(editorDiscoveryCache, (current) => - Option.isNone(current) || current.value.scan !== scan - ? current - : Exit.isSuccess(exit) - ? Option.some({ scan, expiresAtNanos }) - : Option.none(), - ); - yield* Deferred.done(scan, exit); - }), - ), - Effect.interruptible, - Effect.forkIn(scope), - ); - // Claiming the cache entry and starting its scan must not be split by an - // interrupt, or the entry would wait on a scan that never runs. - const acquireEditorDiscovery = Effect.gen(function* () { - const nowNanos = yield* Clock.monotonicTimeNanos; - const [scan, isNewScan] = yield* Ref.modify( - editorDiscoveryCache, - ( - current, - ): [ - [EditorDiscoveryCacheEntry["scan"], boolean], - Option.Option, - ] => { - if ( - Option.isSome(current) && - (current.value.expiresAtNanos === undefined || current.value.expiresAtNanos > nowNanos) - ) { - return [[current.value.scan, false], current]; - } - const scan = Deferred.makeUnsafe>(); - return [[scan, true], Option.some({ scan, expiresAtNanos: undefined })]; - }, - ); - if (isNewScan) { - yield* runEditorDiscovery(scan); - } - return scan; - }).pipe(Effect.uninterruptible); - const cachedAvailableEditors = Effect.flatMap(acquireEditorDiscovery, Deferred.await); + ), + EDITOR_DISCOVERY_CACHE_TTL, + ); + const cachedFileManagerRevealKind = yield* makeStaleWhileRevalidate( + provideCommandResolutionServices(resolveFileManagerRevealKind()).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), + EDITOR_DISCOVERY_CACHE_TTL, + ); return ExternalLauncher.of({ resolveAvailableEditors: () => cachedAvailableEditors, - resolveFileManagerRevealKind: () => - provideCommandResolutionServices(resolveFileManagerRevealKind()).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ), + resolveFileManagerRevealKind: () => cachedFileManagerRevealKind, launchBrowser: (target) => launchBrowser(target).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index ae263ac15fd3..2085d50971b8 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -22,6 +22,7 @@ import * as ExternalLauncher from "./process/externalLauncher.ts"; import { hasCompatibleOrchestrationProtocol, resolveAvailableEditorsForConfig, + resolveOpenDiscoveryForConfig, shouldUseBoundedThreadSnapshot, withLateEditorConfig, } from "./ws.ts"; @@ -234,3 +235,35 @@ it.effect("recovers a reveal kind whose real probe outlasts the config timeout", } }).pipe(Effect.scoped), ); + +it.effect("runs editor, remote open target and direct endpoint discovery side by side", () => + Effect.gen(function* () { + // Each discovery finishes only once the other two have started, so a + // config that ran them one after another would never resolve. + const editorsStarted = yield* Deferred.make(); + const targetsStarted = yield* Deferred.make(); + const endpointsStarted = yield* Deferred.make(); + const startThenAwait = (started: Deferred.Deferred, value: A) => + Deferred.succeed(started, undefined).pipe( + Effect.andThen( + Effect.all([editorsStarted, targetsStarted, endpointsStarted].map(Deferred.await)), + ), + Effect.as(value), + ); + + const discovery = yield* resolveOpenDiscoveryForConfig({ + editors: startThenAwait(editorsStarted, ["file-manager" as const]), + fileManagerRevealKind: Effect.succeed("finder" as const), + remoteOpenTargets: startThenAwait(targetsStarted, []), + directEndpoints: startThenAwait(endpointsStarted, []), + }); + + assert.deepEqual(discovery, { + availableEditors: ["file-manager"], + shellRevealInFileManager: true, + shellRevealInFileManagerKind: "finder", + remoteOpenTargets: [], + directEndpoints: [], + }); + }), +); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index c98daa920421..97f6f59081b1 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -312,8 +312,8 @@ export const withLateEditorConfig = ( editors.join() !== config.availableEditors.join() || (editors.includes("file-manager") && config.shellRevealInFileManagerKind === undefined), ), - // Unbounded, unlike the snapshot: the reveal-kind probe is not shared, so - // a timeout here would cancel a probe that outlasts it every time. + // Unbounded, unlike the snapshot: this exists to deliver a probe that + // outlasts the snapshot's timeout. Stream.mapEffect((editors) => resolveEditorConfig(editors, launcher.resolveFileManagerRevealKind()), ), @@ -356,6 +356,32 @@ export const withLateEditorConfig = ( ); }; +// Discoveries run side by side so the slowest one, not their sum, bounds the +// server config snapshot. Each degrades on timeout rather than failing it. +export const resolveOpenDiscoveryForConfig = (discovery: { + readonly editors: Effect.Effect, E1, R1>; + readonly fileManagerRevealKind: Effect.Effect; + readonly remoteOpenTargets: Effect.Effect, E3, R3>; + readonly directEndpoints: Effect.Effect, E4, R4>; +}) => + Effect.all( + { + editorConfig: resolveAvailableEditorsForConfig(discovery.editors).pipe( + Effect.flatMap((availableEditors) => + resolveEditorConfig( + availableEditors, + resolveFileManagerRevealKindForConfig(discovery.fileManagerRevealKind), + ), + ), + ), + // Same discovery-with-timeout treatment as editors: a slow probe must + // not stall server.getConfig, so it degrades to an empty list. + remoteOpenTargets: resolveAvailableEditorsForConfig(discovery.remoteOpenTargets), + directEndpoints: resolveAvailableEditorsForConfig(discovery.directEndpoints), + }, + { concurrency: "unbounded" }, + ).pipe(Effect.map(({ editorConfig, ...endpoints }) => ({ ...editorConfig, ...endpoints }))); + function unexpectedCompatibilityError(error: never): never { throw new Error(`Unhandled compatibility error: ${String(error)}`); } @@ -1682,10 +1708,12 @@ const layerWsRpc = ( const environment = yield* serverEnvironment.getDescriptor; const auth = yield* serverAuth.getDescriptor(); const scratchWorkspaceRoot = yield* managedFolders.scratchRoot; - const editorConfig = yield* resolveEditorConfig( - yield* resolveAvailableEditorsForConfig(externalLauncher.resolveAvailableEditors()), - resolveFileManagerRevealKindForConfig(externalLauncher.resolveFileManagerRevealKind()), - ); + const discovery = yield* resolveOpenDiscoveryForConfig({ + editors: externalLauncher.resolveAvailableEditors(), + fileManagerRevealKind: externalLauncher.resolveFileManagerRevealKind(), + remoteOpenTargets: remoteOpenTargets.resolveTargets(), + directEndpoints: directEndpoints.resolve(), + }); return { environment, @@ -1695,13 +1723,7 @@ const layerWsRpc = ( keybindings: keybindingsConfig.keybindings, issues: keybindingsConfig.issues, providers, - ...editorConfig, - // Same discovery-with-timeout treatment as editors: a slow probe - // must not stall server.getConfig, so it degrades to no targets. - remoteOpenTargets: yield* resolveAvailableEditorsForConfig( - remoteOpenTargets.resolveTargets(), - ), - directEndpoints: yield* resolveAvailableEditorsForConfig(directEndpoints.resolve()), + ...discovery, observability: { logsDirectoryPath: config.logsDir, localTracingEnabled: true,