authorgravatar for bob@rjf.caBob Fowler <bob@rjf.ca> 2026-10-03 11:45:10-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-03 10:45:10-05:00
log0080e80c00a3e333e3dcafd85b2615d56f84e3b8
treeeba1a02483d8c06a014b5b325d69e7f64f910e5b
parentce90eec1ffc2087395227a610843091f4b260cf7
signature Signed by PGP key B5690EEEBB952194

fix(server): editors appear once a slow discovery scan finishes (#13917)


4 files changed, 359 insertions(+), 59 deletions(-)

apps/server/src/process/externalLauncher.test.ts+21-20
...@@ -6,6 +6,7 @@ import * as NodePath from "node:path";...@@ -6,6 +6,7 @@ import * as NodePath from "node:path";
6import * as NodeServices from "@effect/platform-node/NodeServices";6import * as NodeServices from "@effect/platform-node/NodeServices";
7import { assert, it } from "@effect/vitest";7import { assert, it } from "@effect/vitest";
8import * as ConfigProvider from "effect/ConfigProvider";8import * as ConfigProvider from "effect/ConfigProvider";
9import * as Deferred from "effect/Deferred";
9import * as Effect from "effect/Effect";10import * as Effect from "effect/Effect";
10import * as Fiber from "effect/Fiber";11import * as Fiber from "effect/Fiber";
11import * as FileSystem from "effect/FileSystem";12import * as FileSystem from "effect/FileSystem";
...@@ -1186,26 +1187,24 @@ it.effect("memoizes editor discovery and refreshes after the cache window", () =...@@ -1186,26 +1187,24 @@ it.effect("memoizes editor discovery and refreshes after the cache window", () =
1186 );1187 );
1187});1188});
11881189
1189// A client that disconnects mid-scan interrupts the shared discovery effect on1190// Connects run discovery under a timeout and may disconnect mid-scan. Neither
1190// the connection fiber. The cache must not retain that interrupt: doing so1191// may cancel the scan: on a busy host every connect would time out partway
1191// replayed it to every later connect for the whole TTL, so `server.getConfig`1192// through, cache nothing, and leave every client without editors.
1192// failed and no client could reconnect until the server restarted.1193it.effect("keeps scanning after the caller is interrupted and shares that scan", () => {
1193it.effect("rescans after an interrupted discovery instead of caching the interrupt", () => {
1194 const fileInfo = { type: "File" } as FileSystem.File.Info;1194 const fileInfo = { type: "File" } as FileSystem.File.Info;
1195 let blockFirstScan = true;1195 const release = Deferred.makeUnsafe<void>();
1196 let scans = 0;1196 let parkedStats = 0;
1197 const launcherLayer = ExternalLauncher.layer.pipe(1197 const launcherLayer = ExternalLauncher.layer.pipe(
1198 Layer.provide(1198 Layer.provide(
1199 Layer.mergeAll(1199 Layer.mergeAll(
1200 FileSystem.layerNoop({1200 FileSystem.layerNoop({
1201 // The first scan parks inside `stat` so the interrupt lands while1201 // Scans park inside `stat` until released, so the interrupt lands
1202 // discovery is in flight, which is what a client disconnecting1202 // while discovery is in flight.
1203 // mid-connect does to the shared effect.
1204 stat: () =>1203 stat: () =>
1205 Effect.gen(function* () {1204 Effect.gen(function* () {
1206 scans += 1;1205 if (!Deferred.isDoneUnsafe(release)) {
1207 if (blockFirstScan) {1206 parkedStats += 1;
1208 return yield* Effect.never;1207 yield* Deferred.await(release);
1209 }1208 }
1210 return fileInfo;1209 return fileInfo;
1211 }),1210 }),
...@@ -1222,16 +1221,18 @@ it.effect("rescans after an interrupted discovery instead of caching the interru...@@ -1222,16 +1221,18 @@ it.effect("rescans after an interrupted discovery instead of caching the interru
1222 return Effect.gen(function* () {1221 return Effect.gen(function* () {
1223 const launcher = yield* ExternalLauncher.ExternalLauncher;1222 const launcher = yield* ExternalLauncher.ExternalLauncher;
12241223
1225 const fiber = yield* Effect.forkChild(launcher.resolveAvailableEditors());1224 const interrupted = yield* Effect.forkChild(launcher.resolveAvailableEditors());
1226 yield* Effect.yieldNow;1225 yield* Effect.yieldNow;
1227 yield* Fiber.interrupt(fiber);1226 yield* Fiber.interrupt(interrupted);
12281227
1229 // The next connect must still get a real answer well inside the TTL.1228 // The next connect joins the running scan instead of starting its own.
1230 blockFirstScan = false;1229 const next = yield* Effect.forkChild(launcher.resolveAvailableEditors());
1231 scans = 0;1230 yield* Effect.yieldNow;
1232 const editors = yield* launcher.resolveAvailableEditors();1231 assert.equal(parkedStats, 1);
1232
1233 yield* Deferred.succeed(release, undefined);
1234 const editors = yield* Fiber.join(next);
1233 assert.equal(editors.includes("vscode"), true);1235 assert.equal(editors.includes("vscode"), true);
1234 assert.isAbove(scans, 0);
1235 }).pipe(1236 }).pipe(
1236 Effect.provide(1237 Effect.provide(
1237 Layer.mergeAll(1238 Layer.mergeAll(
apps/server/src/process/externalLauncher.ts+56-23
...@@ -28,13 +28,16 @@ import {...@@ -28,13 +28,16 @@ import {
28import * as Clock from "effect/Clock";28import * as Clock from "effect/Clock";
29import * as Config from "effect/Config";29import * as Config from "effect/Config";
30import * as Context from "effect/Context";30import * as Context from "effect/Context";
31import * as Deferred from "effect/Deferred";
31import * as Effect from "effect/Effect";32import * as Effect from "effect/Effect";
32import * as Encoding from "effect/Encoding";33import * as Encoding from "effect/Encoding";
34import * as Exit from "effect/Exit";
33import * as FileSystem from "effect/FileSystem";35import * as FileSystem from "effect/FileSystem";
34import * as Layer from "effect/Layer";36import * as Layer from "effect/Layer";
35import * as Option from "effect/Option";37import * as Option from "effect/Option";
36import * as Path from "effect/Path";38import * as Path from "effect/Path";
37import * as Ref from "effect/Ref";39import * as Ref from "effect/Ref";
40import * as Scope from "effect/Scope";
38import * as Stream from "effect/Stream";41import * as Stream from "effect/Stream";
39import * as ChildProcess from "effect/unstable/process/ChildProcess";42import * as ChildProcess from "effect/unstable/process/ChildProcess";
40import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";43import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
...@@ -462,21 +465,21 @@ const resolveFileManagerRevealKind = Effect.fn("externalLauncher.resolveFileMana...@@ -462,21 +465,21 @@ const resolveFileManagerRevealKind = Effect.fn("externalLauncher.resolveFileMana
462// the discovered set for a bounded window so repeat connects skip even the465// the discovered set for a bounded window so repeat connects skip even the
463// per-command cache lookups in @t3tools/shared/shell.466// per-command cache lookups in @t3tools/shared/shell.
464//467//
465// This deliberately does not use `Effect.cachedWithTTL`: that memoizes the468// The scan runs on its own fiber in the service scope, and every caller awaits
466// first caller's Exit whatever it is, including an interrupt. Callers run this469// that one scan. Callers apply a timeout (`resolveAvailableEditorsForConfig`)
467// on the connection fiber under a timeout (`resolveAvailableEditorsForConfig`),470// and disconnect mid-connect; neither may cancel a scan other connects are
468// so one client disconnecting mid-scan would cache the interrupt and replay it471// waiting on, or throw away work a slow host (a busy server at startup, a
469// to every later connect for the whole TTL, breaking `server.getConfig`472// long PATH) needs more than one connect to finish. A failed scan clears the
470// permanently. Storing only on success means an interrupted scan leaves the473// entry so the next caller starts over rather than replaying the failure.
471// cache untouched and the next connect simply rescans.
472// Expiry uses the monotonic clock (Clock.currentTimeNanos), matching the474// Expiry uses the monotonic clock (Clock.currentTimeNanos), matching the
473// command-resolution cache in @t3tools/shared/shell, so a backward wall-clock475// command-resolution cache in @t3tools/shared/shell, so a backward wall-clock
474// adjustment cannot keep an expired entry alive.476// adjustment cannot keep an expired entry alive.
475const EDITOR_DISCOVERY_CACHE_TTL_NANOS = 60_000_000_000n;477const EDITOR_DISCOVERY_CACHE_TTL_NANOS = 60_000_000_000n;
476478
477interface EditorDiscoveryCacheEntry {479interface EditorDiscoveryCacheEntry {
478 readonly editors: ReadonlyArray<EditorId>;480 readonly scan: Deferred.Deferred<ReadonlyArray<EditorId>>;
479 readonly expiresAtNanos: bigint;481 /** Undefined while the scan is still running. */
482 readonly expiresAtNanos: bigint | undefined;
480}483}
481484
482/**485/**
...@@ -760,27 +763,57 @@ export const make = Effect.gen(function* () {...@@ -760,27 +763,57 @@ export const make = Effect.gen(function* () {
760 Effect.provideService(Path.Path, path),763 Effect.provideService(Path.Path, path),
761 );764 );
762765
766 const scope = yield* Scope.Scope;
763 const editorDiscoveryCache = yield* Ref.make<Option.Option<EditorDiscoveryCacheEntry>>(767 const editorDiscoveryCache = yield* Ref.make<Option.Option<EditorDiscoveryCacheEntry>>(
764 Option.none(),768 Option.none(),
765 );769 );
766 const cachedAvailableEditors = Effect.gen(function* () {770 const runEditorDiscovery = (scan: Deferred.Deferred<ReadonlyArray<EditorId>>) =>
767 const nowNanos = yield* Clock.currentTimeNanos;771 provideCommandResolutionServices(resolveAvailableEditors()).pipe(
768 const entry = yield* Ref.get(editorDiscoveryCache);
769 if (Option.isSome(entry) && entry.value.expiresAtNanos > nowNanos) {
770 return entry.value.editors;
771 }
772 const editors = yield* provideCommandResolutionServices(resolveAvailableEditors()).pipe(
773 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),772 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
773 Effect.onExit((exit) =>
774 Effect.gen(function* () {
775 const expiresAtNanos = (yield* Clock.currentTimeNanos) + EDITOR_DISCOVERY_CACHE_TTL_NANOS;
776 yield* Ref.update(editorDiscoveryCache, (current) =>
777 Option.isNone(current) || current.value.scan !== scan
778 ? current
779 : Exit.isSuccess(exit)
780 ? Option.some({ scan, expiresAtNanos })
781 : Option.none(),
782 );
783 yield* Deferred.done(scan, exit);
784 }),
785 ),
786 Effect.interruptible,
787 Effect.forkIn(scope),
774 );788 );
775 yield* Ref.set(789 // Claiming the cache entry and starting its scan must not be split by an
790 // interrupt, or the entry would wait on a scan that never runs.
791 const acquireEditorDiscovery = Effect.gen(function* () {
792 const nowNanos = yield* Clock.currentTimeNanos;
793 const [scan, isNewScan] = yield* Ref.modify(
776 editorDiscoveryCache,794 editorDiscoveryCache,
777 Option.some({795 (
778 editors,796 current,
779 expiresAtNanos: nowNanos + EDITOR_DISCOVERY_CACHE_TTL_NANOS,797 ): [
780 }),798 [EditorDiscoveryCacheEntry["scan"], boolean],
799 Option.Option<EditorDiscoveryCacheEntry>,
800 ] => {
801 if (
802 Option.isSome(current) &&
803 (current.value.expiresAtNanos === undefined || current.value.expiresAtNanos > nowNanos)
804 ) {
805 return [[current.value.scan, false], current];
806 }
807 const scan = Deferred.makeUnsafe<ReadonlyArray<EditorId>>();
808 return [[scan, true], Option.some({ scan, expiresAtNanos: undefined })];
809 },
781 );810 );
782 return editors;811 if (isNewScan) {
783 });812 yield* runEditorDiscovery(scan);
813 }
814 return scan;
815 }).pipe(Effect.uninterruptible);
816 const cachedAvailableEditors = Effect.flatMap(acquireEditorDiscovery, Deferred.await);
784817
785 return ExternalLauncher.of({818 return ExternalLauncher.of({
786 resolveAvailableEditors: () => cachedAvailableEditors,819 resolveAvailableEditors: () => cachedAvailableEditors,
apps/server/src/ws.test.ts+187-1
...@@ -1,15 +1,29 @@...@@ -1,15 +1,29 @@
1import { assert, it } from "@effect/vitest";1import { assert, it } from "@effect/vitest";
2import { ORCHESTRATION_PROTOCOL_VERSION } from "@t3tools/contracts";2import {
3 ORCHESTRATION_PROTOCOL_VERSION,
4 type ServerConfig,
5 type ServerConfigStreamEvent,
6} from "@t3tools/contracts";
7import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
8import * as ConfigProvider from "effect/ConfigProvider";
3import * as Deferred from "effect/Deferred";9import * as Deferred from "effect/Deferred";
4import * as Duration from "effect/Duration";10import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";11import * as Effect from "effect/Effect";
6import * as Fiber from "effect/Fiber";12import * as Fiber from "effect/Fiber";
13import * as FileSystem from "effect/FileSystem";
14import * as Layer from "effect/Layer";
15import * as Path from "effect/Path";
16import * as Queue from "effect/Queue";
17import * as Stream from "effect/Stream";
7import * as TestClock from "effect/testing/TestClock";18import * as TestClock from "effect/testing/TestClock";
19import { ChildProcessSpawner } from "effect/unstable/process";
820
21import * as ExternalLauncher from "./process/externalLauncher.ts";
9import {22import {
10 hasCompatibleOrchestrationProtocol,23 hasCompatibleOrchestrationProtocol,
11 resolveAvailableEditorsForConfig,24 resolveAvailableEditorsForConfig,
12 shouldUseBoundedThreadSnapshot,25 shouldUseBoundedThreadSnapshot,
26 withLateEditorConfig,
13} from "./ws.ts";27} from "./ws.ts";
1428
15it("accepts only the current orchestration protocol before websocket RPC setup", () => {29it("accepts only the current orchestration protocol before websocket RPC setup", () => {
...@@ -48,3 +62,175 @@ it.effect("does not block server config when editor discovery never resolves", (...@@ -48,3 +62,175 @@ it.effect("does not block server config when editor discovery never resolves", (
48 assert.deepEqual(availableEditors, []);62 assert.deepEqual(availableEditors, []);
49 }),63 }),
50);64);
65
66// Only the fields the late-editor fold reads or rewrites.
67const snapshotConfig = (fields: Partial<ServerConfig>) =>
68 ({ availableEditors: [], settings: {}, ...fields }) as unknown as ServerConfig;
69
70const settingsUpdated = (settings: object): ServerConfigStreamEvent => ({
71 version: 1,
72 type: "settingsUpdated",
73 payload: { settings: settings as ServerConfig["settings"] },
74});
75
76it.effect("resends late editors without rolling back updates already sent", () =>
77 Effect.gen(function* () {
78 const settingsSent = yield* Deferred.make<void>();
79 const events = yield* withLateEditorConfig(
80 snapshotConfig({ settings: { enableProviderUpdateChecks: true } as never }),
81 Stream.make(settingsUpdated({ enableProviderUpdateChecks: false })),
82 {
83 resolveAvailableEditors: () => Effect.succeed(["file-manager"]),
84 // Holds the late snapshot until the settings change has gone out.
85 resolveFileManagerRevealKind: () =>
86 Deferred.await(settingsSent).pipe(Effect.as("file-explorer" as const)),
87 },
88 ).pipe(
89 Stream.tap((event) =>
90 event.type === "settingsUpdated" ? Deferred.succeed(settingsSent, undefined) : Effect.void,
91 ),
92 Stream.runCollect,
93 );
94
95 const [first, second] = Array.from(events);
96 assert.equal(events.length, 2);
97 assert.equal(first?.type, "settingsUpdated");
98 assert.equal(second?.type, "snapshot");
99 if (second?.type === "snapshot") {
100 assert.deepEqual(second.config.availableEditors, ["file-manager"]);
101 assert.equal(second.config.shellRevealInFileManagerKind, "file-explorer");
102 assert.deepEqual(second.config.settings, { enableProviderUpdateChecks: false } as never);
103 }
104 }),
105);
106
107it.effect("sends no late snapshot when the scan matches the snapshot", () =>
108 Effect.gen(function* () {
109 const events = yield* withLateEditorConfig(
110 snapshotConfig({ availableEditors: ["vscode"] }),
111 Stream.empty,
112 {
113 resolveAvailableEditors: () => Effect.succeed(["vscode"]),
114 resolveFileManagerRevealKind: () => Effect.succeed(undefined),
115 },
116 ).pipe(Stream.runCollect);
117
118 assert.equal(events.length, 0);
119 }),
120);
121
122it.effect("resends a file manager reveal kind that missed the snapshot", () =>
123 Effect.gen(function* () {
124 const events = yield* withLateEditorConfig(
125 snapshotConfig({ availableEditors: ["file-manager"] }),
126 Stream.empty,
127 {
128 resolveAvailableEditors: () => Effect.succeed(["file-manager"]),
129 resolveFileManagerRevealKind: () => Effect.succeed("file-explorer"),
130 },
131 ).pipe(Stream.runCollect);
132
133 const [late] = Array.from(events);
134 assert.equal(events.length, 1);
135 assert.equal(late?.type, "snapshot");
136 if (late?.type === "snapshot") {
137 assert.equal(late.config.shellRevealInFileManagerKind, "file-explorer");
138 }
139 }),
140);
141
142// The real launcher on Windows over a filesystem whose probes park until
143// released, like a host too busy to finish discovery inside the snapshot timeout.
144const makeParkedWindowsLauncher = Effect.gen(function* () {
145 const parkedProbes = yield* Queue.unbounded<void>();
146 const release = yield* Deferred.make<void>();
147 const launcher = yield* ExternalLauncher.make.pipe(
148 Effect.provide(
149 Layer.mergeAll(
150 FileSystem.layerNoop({
151 stat: () =>
152 Queue.offer(parkedProbes, undefined).pipe(
153 Effect.andThen(Deferred.await(release)),
154 Effect.as({ type: "File" } as FileSystem.File.Info),
155 ),
156 }),
157 Path.layer,
158 Layer.succeed(
159 ChildProcessSpawner.ChildProcessSpawner,
160 ChildProcessSpawner.make(() => Effect.die("unexpected spawn")),
161 ),
162 ),
163 ),
164 );
165 const onWindows = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
166 effect.pipe(
167 Effect.provideService(HostProcessPlatform, "win32"),
168 Effect.provide(
169 ConfigProvider.layer(
170 ConfigProvider.fromEnv({
171 env: { PATH: "C:\\t3-late-editors-test", PATHEXT: ".EXE" },
172 }),
173 ),
174 ),
175 );
176 return {
177 editors: {
178 resolveAvailableEditors: () => onWindows(launcher.resolveAvailableEditors()),
179 resolveFileManagerRevealKind: () => onWindows(launcher.resolveFileManagerRevealKind()),
180 },
181 probeParked: Queue.take(parkedProbes),
182 releaseProbes: Deferred.succeed(release, undefined),
183 };
184});
185
186it.effect("recovers editors after a real scan outlasts the config timeout", () =>
187 Effect.gen(function* () {
188 const { editors, probeParked, releaseProbes } = yield* makeParkedWindowsLauncher;
189
190 const snapshotFiber = yield* resolveAvailableEditorsForConfig(
191 editors.resolveAvailableEditors(),
192 ).pipe(Effect.forkChild);
193 yield* probeParked;
194 yield* TestClock.adjust(Duration.seconds(5));
195 const snapshotEditors = yield* Fiber.join(snapshotFiber);
196 assert.deepEqual(snapshotEditors, []);
197
198 const lateFiber = yield* withLateEditorConfig(
199 snapshotConfig({ availableEditors: snapshotEditors }),
200 Stream.empty,
201 editors,
202 ).pipe(Stream.runCollect, Effect.forkChild);
203 yield* releaseProbes;
204
205 const [late] = Array.from(yield* Fiber.join(lateFiber));
206 assert.equal(late?.type, "snapshot");
207 if (late?.type === "snapshot") {
208 assert.equal(late.config.availableEditors.includes("vscode"), true);
209 }
210 }).pipe(Effect.scoped),
211);
212
213it.effect("recovers a reveal kind whose real probe outlasts the config timeout", () =>
214 Effect.gen(function* () {
215 const { editors, probeParked, releaseProbes } = yield* makeParkedWindowsLauncher;
216
217 // The snapshot's bounded probe timed out: file manager, but no reveal kind.
218 const lateFiber = yield* withLateEditorConfig(
219 snapshotConfig({ availableEditors: ["file-manager"] }),
220 Stream.empty,
221 {
222 resolveAvailableEditors: () => Effect.succeed(["file-manager"]),
223 resolveFileManagerRevealKind: editors.resolveFileManagerRevealKind,
224 },
225 ).pipe(Stream.runCollect, Effect.forkChild);
226 yield* probeParked;
227 yield* TestClock.adjust(Duration.seconds(6));
228 yield* releaseProbes;
229
230 const [late] = Array.from(yield* Fiber.join(lateFiber));
231 assert.equal(late?.type, "snapshot");
232 if (late?.type === "snapshot") {
233 assert.equal(late.config.shellRevealInFileManagerKind, "file-explorer");
234 }
235 }).pipe(Effect.scoped),
236);
apps/server/src/ws.ts+95-15
...@@ -76,6 +76,8 @@ import {...@@ -76,6 +76,8 @@ import {
76 type RelayClientInstallProgressEvent,76 type RelayClientInstallProgressEvent,
77 type ServerSelfUpdateError,77 type ServerSelfUpdateError,
78 type ServerSelfUpdateProgressEvent,78 type ServerSelfUpdateProgressEvent,
79 type ServerConfig as ClientServerConfig,
80 type ServerConfigStreamEvent,
79 type ServerLifecycleStreamEvent,81 type ServerLifecycleStreamEvent,
80 type FilesystemBrowseFailure,82 type FilesystemBrowseFailure,
81 FilesystemBrowseError,83 FilesystemBrowseError,
...@@ -261,6 +263,94 @@ const resolveFileManagerRevealKindForConfig = <E, R>(...@@ -261,6 +263,94 @@ const resolveFileManagerRevealKindForConfig = <E, R>(
261 discovery: Effect.Effect<FileManagerRevealKind | undefined, E, R>,263 discovery: Effect.Effect<FileManagerRevealKind | undefined, E, R>,
262) => resolveDiscoveryForConfig(discovery, () => undefined);264) => resolveDiscoveryForConfig(discovery, () => undefined);
263265
266type EditorDiscovery = Pick<
267 ExternalLauncher.ExternalLauncher["Service"],
268 "resolveAvailableEditors" | "resolveFileManagerRevealKind"
269>;
270
271// The config fields that follow from which editors are installed.
272const resolveEditorConfig = <E, R>(
273 availableEditors: ReadonlyArray<EditorId>,
274 revealKind: Effect.Effect<FileManagerRevealKind | undefined, E, R>,
275) =>
276 Effect.gen(function* () {
277 const fileManagerRevealKind = availableEditors.includes("file-manager")
278 ? yield* revealKind
279 : undefined;
280 return {
281 availableEditors,
282 ...(fileManagerRevealKind === undefined
283 ? {}
284 : {
285 shellRevealInFileManager: true,
286 shellRevealInFileManagerKind: fileManagerRevealKind,
287 }),
288 };
289 });
290
291/**
292 * Live config updates that follow a snapshot of `config`. A busy host can
293 * outlast the snapshot's discovery timeouts, which send no editors, or no
294 * reveal kind for the file manager. The scan keeps running, so once it lands
295 * this resends the config: clients replace theirs on any snapshot. The resent
296 * config is folded from the live updates already sent, so it cannot roll back
297 * a change that landed while the scan ran.
298 */
299export const withLateEditorConfig = <E, R>(
300 config: ClientServerConfig,
301 liveUpdates: Stream.Stream<ServerConfigStreamEvent, E, R>,
302 launcher: EditorDiscovery,
303) => {
304 const lateEditorConfig = Stream.fromEffect(launcher.resolveAvailableEditors()).pipe(
305 Stream.filter(
306 (editors) =>
307 editors.join() !== config.availableEditors.join() ||
308 (editors.includes("file-manager") && config.shellRevealInFileManagerKind === undefined),
309 ),
310 // Unbounded, unlike the snapshot: the reveal-kind probe is not shared, so
311 // a timeout here would cancel a probe that outlasts it every time.
312 Stream.mapEffect((editors) =>
313 resolveEditorConfig(editors, launcher.resolveFileManagerRevealKind()),
314 ),
315 Stream.filter(
316 (editorConfig) =>
317 editorConfig.availableEditors.join() !== config.availableEditors.join() ||
318 editorConfig.shellRevealInFileManagerKind !== config.shellRevealInFileManagerKind,
319 ),
320 Stream.map((editorConfig) => ({ type: "editorsResolved" as const, editorConfig })),
321 );
322
323 return Stream.merge(liveUpdates, lateEditorConfig).pipe(
324 Stream.mapAccum(
325 (): ClientServerConfig => config,
326 (current, event): readonly [ClientServerConfig, ReadonlyArray<ServerConfigStreamEvent>] => {
327 switch (event.type) {
328 case "editorsResolved": {
329 const {
330 availableEditors: _editors,
331 shellRevealInFileManager: _reveal,
332 shellRevealInFileManagerKind: _revealKind,
333 ...rest
334 } = current;
335 const next = { ...rest, ...event.editorConfig };
336 return [next, [{ version: 1, type: "snapshot", config: next }]];
337 }
338 case "keybindingsUpdated":
339 return [{ ...current, ...event.payload }, [event]];
340 case "providerStatuses":
341 return [{ ...current, providers: event.payload.providers }, [event]];
342 case "settingsUpdated":
343 return [{ ...current, settings: event.payload.settings }, [event]];
344 // Themes and usage-limit sources never ride in a snapshot; clients
345 // carry their projected values across one.
346 default:
347 return [current, [event]];
348 }
349 },
350 ),
351 );
352};
353
264function unexpectedCompatibilityError(error: never): never {354function unexpectedCompatibilityError(error: never): never {
265 throw new Error(`Unhandled compatibility error: ${String(error)}`);355 throw new Error(`Unhandled compatibility error: ${String(error)}`);
266}356}
...@@ -1620,14 +1710,10 @@ const makeWsRpcLayer = (...@@ -1620,14 +1710,10 @@ const makeWsRpcLayer = (
1620 const environment = yield* serverEnvironment.getDescriptor;1710 const environment = yield* serverEnvironment.getDescriptor;
1621 const auth = yield* serverAuth.getDescriptor();1711 const auth = yield* serverAuth.getDescriptor();
1622 const scratchWorkspaceRoot = yield* managedFolders.scratchRoot;1712 const scratchWorkspaceRoot = yield* managedFolders.scratchRoot;
1623 const availableEditors: ReadonlyArray<EditorId> = yield* resolveAvailableEditorsForConfig(1713 const editorConfig = yield* resolveEditorConfig(
1624 externalLauncher.resolveAvailableEditors(),1714 yield* resolveAvailableEditorsForConfig(externalLauncher.resolveAvailableEditors()),
1715 resolveFileManagerRevealKindForConfig(externalLauncher.resolveFileManagerRevealKind()),
1625 );1716 );
1626 const fileManagerRevealKind = availableEditors.includes("file-manager")
1627 ? yield* resolveFileManagerRevealKindForConfig(
1628 externalLauncher.resolveFileManagerRevealKind(),
1629 )
1630 : undefined;
16311717
1632 return {1718 return {
1633 environment,1719 environment,
...@@ -1637,7 +1723,7 @@ const makeWsRpcLayer = (...@@ -1637,7 +1723,7 @@ const makeWsRpcLayer = (
1637 keybindings: keybindingsConfig.keybindings,1723 keybindings: keybindingsConfig.keybindings,
1638 issues: keybindingsConfig.issues,1724 issues: keybindingsConfig.issues,
1639 providers,1725 providers,
1640 availableEditors,1726 ...editorConfig,
1641 // Same discovery-with-timeout treatment as editors: a slow probe1727 // Same discovery-with-timeout treatment as editors: a slow probe
1642 // must not stall server.getConfig, so it degrades to no targets.1728 // must not stall server.getConfig, so it degrades to no targets.
1643 remoteOpenTargets: yield* resolveAvailableEditorsForConfig(1729 remoteOpenTargets: yield* resolveAvailableEditorsForConfig(
...@@ -1659,12 +1745,6 @@ const makeWsRpcLayer = (...@@ -1659,12 +1745,6 @@ const makeWsRpcLayer = (
1659 },1745 },
1660 settings,1746 settings,
1661 shellResumeCompletionMarker: true,1747 shellResumeCompletionMarker: true,
1662 ...(fileManagerRevealKind === undefined
1663 ? {}
1664 : {
1665 shellRevealInFileManager: true,
1666 shellRevealInFileManagerKind: fileManagerRevealKind,
1667 }),
1668 threadResumeCompletionMarker: true,1748 threadResumeCompletionMarker: true,
1669 threadSnapshotPagination: true,1749 threadSnapshotPagination: true,
1670 ...Option.match(scratchWorkspaceRoot, {1750 ...Option.match(scratchWorkspaceRoot, {
...@@ -3632,7 +3712,7 @@ const makeWsRpcLayer = (...@@ -3632,7 +3712,7 @@ const makeWsRpcLayer = (
36323712
3633 return Stream.concat(3713 return Stream.concat(
3634 rpcInitialItems([{ version: 1 as const, type: "snapshot" as const, config }]),3714 rpcInitialItems([{ version: 1 as const, type: "snapshot" as const, config }]),
3635 liveUpdates,3715 withLateEditorConfig(config, liveUpdates, externalLauncher),
3636 );3716 );
3637 }),3717 }),
3638 { "rpc.aggregate": "server" },3718 { "rpc.aggregate": "server" },