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";
66import * as NodeServices from "@effect/platform-node/NodeServices";
77import { assert, it } from "@effect/vitest";
88import * as ConfigProvider from "effect/ConfigProvider";
9import * as Deferred from "effect/Deferred";
910import * as Effect from "effect/Effect";
1011import * as Fiber from "effect/Fiber";
1112import * as FileSystem from "effect/FileSystem";
......@@ -1186,26 +1187,24 @@ it.effect("memoizes editor discovery and refreshes after the cache window", () =
11861187 );
11871188});
11881189
1189// A client that disconnects mid-scan interrupts the shared discovery effect on
1190// the connection fiber. The cache must not retain that interrupt: doing so
1191// replayed it to every later connect for the whole TTL, so `server.getConfig`
1192// failed and no client could reconnect until the server restarted.
1193it.effect("rescans after an interrupted discovery instead of caching the interrupt", () => {
1190// Connects run discovery under a timeout and may disconnect mid-scan. Neither
1191// may cancel the scan: on a busy host every connect would time out partway
1192// through, cache nothing, and leave every client without editors.
1193it.effect("keeps scanning after the caller is interrupted and shares that scan", () => {
11941194 const fileInfo = { type: "File" } as FileSystem.File.Info;
1195 let blockFirstScan = true;
1196 let scans = 0;
1195 const release = Deferred.makeUnsafe<void>();
1196 let parkedStats = 0;
11971197 const launcherLayer = ExternalLauncher.layer.pipe(
11981198 Layer.provide(
11991199 Layer.mergeAll(
12001200 FileSystem.layerNoop({
1201 // The first scan parks inside `stat` so the interrupt lands while
1202 // discovery is in flight, which is what a client disconnecting
1203 // mid-connect does to the shared effect.
1201 // Scans park inside `stat` until released, so the interrupt lands
1202 // while discovery is in flight.
12041203 stat: () =>
12051204 Effect.gen(function* () {
1206 scans += 1;
1207 if (blockFirstScan) {
1208 return yield* Effect.never;
1205 if (!Deferred.isDoneUnsafe(release)) {
1206 parkedStats += 1;
1207 yield* Deferred.await(release);
12091208 }
12101209 return fileInfo;
12111210 }),
......@@ -1222,16 +1221,18 @@ it.effect("rescans after an interrupted discovery instead of caching the interru
12221221 return Effect.gen(function* () {
12231222 const launcher = yield* ExternalLauncher.ExternalLauncher;
12241223
1225 const fiber = yield* Effect.forkChild(launcher.resolveAvailableEditors());
1224 const interrupted = yield* Effect.forkChild(launcher.resolveAvailableEditors());
12261225 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.
1230 blockFirstScan = false;
1231 scans = 0;
1232 const editors = yield* launcher.resolveAvailableEditors();
1228 // The next connect joins the running scan instead of starting its own.
1229 const next = yield* Effect.forkChild(launcher.resolveAvailableEditors());
1230 yield* Effect.yieldNow;
1231 assert.equal(parkedStats, 1);
1232
1233 yield* Deferred.succeed(release, undefined);
1234 const editors = yield* Fiber.join(next);
12331235 assert.equal(editors.includes("vscode"), true);
1234 assert.isAbove(scans, 0);
12351236 }).pipe(
12361237 Effect.provide(
12371238 Layer.mergeAll(
apps/server/src/process/externalLauncher.ts+56-23
......@@ -28,13 +28,16 @@ import {
2828import * as Clock from "effect/Clock";
2929import * as Config from "effect/Config";
3030import * as Context from "effect/Context";
31import * as Deferred from "effect/Deferred";
3132import * as Effect from "effect/Effect";
3233import * as Encoding from "effect/Encoding";
34import * as Exit from "effect/Exit";
3335import * as FileSystem from "effect/FileSystem";
3436import * as Layer from "effect/Layer";
3537import * as Option from "effect/Option";
3638import * as Path from "effect/Path";
3739import * as Ref from "effect/Ref";
40import * as Scope from "effect/Scope";
3841import * as Stream from "effect/Stream";
3942import * as ChildProcess from "effect/unstable/process/ChildProcess";
4043import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
......@@ -462,21 +465,21 @@ const resolveFileManagerRevealKind = Effect.fn("externalLauncher.resolveFileMana
462465// the discovered set for a bounded window so repeat connects skip even the
463466// per-command cache lookups in @t3tools/shared/shell.
464467//
465// This deliberately does not use `Effect.cachedWithTTL`: that memoizes the
466// first caller's Exit whatever it is, including an interrupt. Callers run this
467// on the connection fiber under a timeout (`resolveAvailableEditorsForConfig`),
468// so one client disconnecting mid-scan would cache the interrupt and replay it
469// to every later connect for the whole TTL, breaking `server.getConfig`
470// permanently. Storing only on success means an interrupted scan leaves the
471// cache untouched and the next connect simply rescans.
468// The scan runs on its own fiber in the service scope, and every caller awaits
469// that one scan. Callers apply a timeout (`resolveAvailableEditorsForConfig`)
470// and disconnect mid-connect; neither may cancel a scan other connects are
471// waiting on, or throw away work a slow host (a busy server at startup, a
472// long PATH) needs more than one connect to finish. A failed scan clears the
473// entry so the next caller starts over rather than replaying the failure.
472474// Expiry uses the monotonic clock (Clock.currentTimeNanos), matching the
473475// command-resolution cache in @t3tools/shared/shell, so a backward wall-clock
474476// adjustment cannot keep an expired entry alive.
475477const EDITOR_DISCOVERY_CACHE_TTL_NANOS = 60_000_000_000n;
476478
477479interface EditorDiscoveryCacheEntry {
478 readonly editors: ReadonlyArray<EditorId>;
479 readonly expiresAtNanos: bigint;
480 readonly scan: Deferred.Deferred<ReadonlyArray<EditorId>>;
481 /** Undefined while the scan is still running. */
482 readonly expiresAtNanos: bigint | undefined;
480483}
481484
482485/**
......@@ -760,27 +763,57 @@ export const make = Effect.gen(function* () {
760763 Effect.provideService(Path.Path, path),
761764 );
762765
766 const scope = yield* Scope.Scope;
763767 const editorDiscoveryCache = yield* Ref.make<Option.Option<EditorDiscoveryCacheEntry>>(
764768 Option.none(),
765769 );
766 const cachedAvailableEditors = Effect.gen(function* () {
767 const nowNanos = yield* Clock.currentTimeNanos;
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(
770 const runEditorDiscovery = (scan: Deferred.Deferred<ReadonlyArray<EditorId>>) =>
771 provideCommandResolutionServices(resolveAvailableEditors()).pipe(
773772 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),
774788 );
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(
776794 editorDiscoveryCache,
777 Option.some({
778 editors,
779 expiresAtNanos: nowNanos + EDITOR_DISCOVERY_CACHE_TTL_NANOS,
780 }),
795 (
796 current,
797 ): [
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 },
781810 );
782 return editors;
783 });
811 if (isNewScan) {
812 yield* runEditorDiscovery(scan);
813 }
814 return scan;
815 }).pipe(Effect.uninterruptible);
816 const cachedAvailableEditors = Effect.flatMap(acquireEditorDiscovery, Deferred.await);
784817
785818 return ExternalLauncher.of({
786819 resolveAvailableEditors: () => cachedAvailableEditors,
apps/server/src/ws.test.ts+187-1
......@@ -1,15 +1,29 @@
11import { 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";
39import * as Deferred from "effect/Deferred";
410import * as Duration from "effect/Duration";
511import * as Effect from "effect/Effect";
612import * 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";
718import * as TestClock from "effect/testing/TestClock";
19import { ChildProcessSpawner } from "effect/unstable/process";
820
21import * as ExternalLauncher from "./process/externalLauncher.ts";
922import {
1023 hasCompatibleOrchestrationProtocol,
1124 resolveAvailableEditorsForConfig,
1225 shouldUseBoundedThreadSnapshot,
26 withLateEditorConfig,
1327} from "./ws.ts";
1428
1529it("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", (
4862 assert.deepEqual(availableEditors, []);
4963 }),
5064);
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 {
7676 type RelayClientInstallProgressEvent,
7777 type ServerSelfUpdateError,
7878 type ServerSelfUpdateProgressEvent,
79 type ServerConfig as ClientServerConfig,
80 type ServerConfigStreamEvent,
7981 type ServerLifecycleStreamEvent,
8082 type FilesystemBrowseFailure,
8183 FilesystemBrowseError,
......@@ -261,6 +263,94 @@ const resolveFileManagerRevealKindForConfig = <E, R>(
261263 discovery: Effect.Effect<FileManagerRevealKind | undefined, E, R>,
262264) => 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
264354function unexpectedCompatibilityError(error: never): never {
265355 throw new Error(`Unhandled compatibility error: ${String(error)}`);
266356}
......@@ -1620,14 +1710,10 @@ const makeWsRpcLayer = (
16201710 const environment = yield* serverEnvironment.getDescriptor;
16211711 const auth = yield* serverAuth.getDescriptor();
16221712 const scratchWorkspaceRoot = yield* managedFolders.scratchRoot;
1623 const availableEditors: ReadonlyArray<EditorId> = yield* resolveAvailableEditorsForConfig(
1624 externalLauncher.resolveAvailableEditors(),
1713 const editorConfig = yield* resolveEditorConfig(
1714 yield* resolveAvailableEditorsForConfig(externalLauncher.resolveAvailableEditors()),
1715 resolveFileManagerRevealKindForConfig(externalLauncher.resolveFileManagerRevealKind()),
16251716 );
1626 const fileManagerRevealKind = availableEditors.includes("file-manager")
1627 ? yield* resolveFileManagerRevealKindForConfig(
1628 externalLauncher.resolveFileManagerRevealKind(),
1629 )
1630 : undefined;
16311717
16321718 return {
16331719 environment,
......@@ -1637,7 +1723,7 @@ const makeWsRpcLayer = (
16371723 keybindings: keybindingsConfig.keybindings,
16381724 issues: keybindingsConfig.issues,
16391725 providers,
1640 availableEditors,
1726 ...editorConfig,
16411727 // Same discovery-with-timeout treatment as editors: a slow probe
16421728 // must not stall server.getConfig, so it degrades to no targets.
16431729 remoteOpenTargets: yield* resolveAvailableEditorsForConfig(
......@@ -1659,12 +1745,6 @@ const makeWsRpcLayer = (
16591745 },
16601746 settings,
16611747 shellResumeCompletionMarker: true,
1662 ...(fileManagerRevealKind === undefined
1663 ? {}
1664 : {
1665 shellRevealInFileManager: true,
1666 shellRevealInFileManagerKind: fileManagerRevealKind,
1667 }),
16681748 threadResumeCompletionMarker: true,
16691749 threadSnapshotPagination: true,
16701750 ...Option.match(scratchWorkspaceRoot, {
......@@ -3632,7 +3712,7 @@ const makeWsRpcLayer = (
36323712
36333713 return Stream.concat(
36343714 rpcInitialItems([{ version: 1 as const, type: "snapshot" as const, config }]),
3635 liveUpdates,
3715 withLateEditorConfig(config, liveUpdates, externalLauncher),
36363716 );
36373717 }),
36383718 { "rpc.aggregate": "server" },