| author | |
| committer | |
| log | 0080e80c00a3e333e3dcafd85b2615d56f84e3b8 |
| tree | eba1a02483d8c06a014b5b325d69e7f64f910e5b |
| parent | ce90eec1ffc2087395227a610843091f4b260cf7 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 7 | 7 | import { assert, it } from "@effect/vitest"; |
| 8 | 8 | import * as ConfigProvider from "effect/ConfigProvider"; |
| 9 | import * as Deferred from "effect/Deferred"; | |
| 9 | 10 | import * as Effect from "effect/Effect"; |
| 10 | 11 | import * as Fiber from "effect/Fiber"; |
| 11 | 12 | import * as FileSystem from "effect/FileSystem"; |
| ... | ... | @@ -1186,26 +1187,24 @@ it.effect("memoizes editor discovery and refreshes after the cache window", () = |
| 1186 | 1187 | ); |
| 1187 | 1188 | }); |
| 1188 | 1189 | |
| 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. | |
| 1193 | it.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. | |
| 1193 | it.effect("keeps scanning after the caller is interrupted and shares that scan", () => { | |
| 1194 | 1194 | 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; | |
| 1197 | 1197 | const launcherLayer = ExternalLauncher.layer.pipe( |
| 1198 | 1198 | Layer.provide( |
| 1199 | 1199 | Layer.mergeAll( |
| 1200 | 1200 | 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. | |
| 1204 | 1203 | stat: () => |
| 1205 | 1204 | 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); | |
| 1209 | 1208 | } |
| 1210 | 1209 | return fileInfo; |
| 1211 | 1210 | }), |
| ... | ... | @@ -1222,16 +1221,18 @@ it.effect("rescans after an interrupted discovery instead of caching the interru |
| 1222 | 1221 | return Effect.gen(function* () { |
| 1223 | 1222 | const launcher = yield* ExternalLauncher.ExternalLauncher; |
| 1224 | 1223 | |
| 1225 | const fiber = yield* Effect.forkChild(launcher.resolveAvailableEditors()); | |
| 1224 | const interrupted = yield* Effect.forkChild(launcher.resolveAvailableEditors()); | |
| 1226 | 1225 | yield* Effect.yieldNow; |
| 1227 | yield* Fiber.interrupt(fiber); | |
| 1226 | yield* Fiber.interrupt(interrupted); | |
| 1228 | 1227 | |
| 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); | |
| 1233 | 1235 | assert.equal(editors.includes("vscode"), true); |
| 1234 | assert.isAbove(scans, 0); | |
| 1235 | 1236 | }).pipe( |
| 1236 | 1237 | Effect.provide( |
| 1237 | 1238 | Layer.mergeAll( |
apps/server/src/process/externalLauncher.ts+56-23| ... | ... | @@ -28,13 +28,16 @@ import { |
| 28 | 28 | import * as Clock from "effect/Clock"; |
| 29 | 29 | import * as Config from "effect/Config"; |
| 30 | 30 | import * as Context from "effect/Context"; |
| 31 | import * as Deferred from "effect/Deferred"; | |
| 31 | 32 | import * as Effect from "effect/Effect"; |
| 32 | 33 | import * as Encoding from "effect/Encoding"; |
| 34 | import * as Exit from "effect/Exit"; | |
| 33 | 35 | import * as FileSystem from "effect/FileSystem"; |
| 34 | 36 | import * as Layer from "effect/Layer"; |
| 35 | 37 | import * as Option from "effect/Option"; |
| 36 | 38 | import * as Path from "effect/Path"; |
| 37 | 39 | import * as Ref from "effect/Ref"; |
| 40 | import * as Scope from "effect/Scope"; | |
| 38 | 41 | import * as Stream from "effect/Stream"; |
| 39 | 42 | import * as ChildProcess from "effect/unstable/process/ChildProcess"; |
| 40 | 43 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; |
| ... | ... | @@ -462,21 +465,21 @@ const resolveFileManagerRevealKind = Effect.fn("externalLauncher.resolveFileMana |
| 462 | 465 | // the discovered set for a bounded window so repeat connects skip even the |
| 463 | 466 | // per-command cache lookups in @t3tools/shared/shell. |
| 464 | 467 | // |
| 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. | |
| 472 | 474 | // Expiry uses the monotonic clock (Clock.currentTimeNanos), matching the |
| 473 | 475 | // command-resolution cache in @t3tools/shared/shell, so a backward wall-clock |
| 474 | 476 | // adjustment cannot keep an expired entry alive. |
| 475 | 477 | const EDITOR_DISCOVERY_CACHE_TTL_NANOS = 60_000_000_000n; |
| 476 | 478 | |
| 477 | 479 | interface 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; | |
| 480 | 483 | } |
| 481 | 484 | |
| 482 | 485 | /** |
| ... | ... | @@ -760,27 +763,57 @@ export const make = Effect.gen(function* () { |
| 760 | 763 | Effect.provideService(Path.Path, path), |
| 761 | 764 | ); |
| 762 | 765 | |
| 766 | const scope = yield* Scope.Scope; | |
| 763 | 767 | const editorDiscoveryCache = yield* Ref.make<Option.Option<EditorDiscoveryCacheEntry>>( |
| 764 | 768 | Option.none(), |
| 765 | 769 | ); |
| 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( | |
| 773 | 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 | 794 | 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 | }, | |
| 781 | 810 | ); |
| 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); | |
| 784 | 817 | |
| 785 | 818 | return ExternalLauncher.of({ |
| 786 | 819 | resolveAvailableEditors: () => cachedAvailableEditors, |
apps/server/src/ws.test.ts+187-1| ... | ... | @@ -1,15 +1,29 @@ |
| 1 | 1 | import { assert, it } from "@effect/vitest"; |
| 2 | import { ORCHESTRATION_PROTOCOL_VERSION } from "@t3tools/contracts"; | |
| 2 | import { | |
| 3 | ORCHESTRATION_PROTOCOL_VERSION, | |
| 4 | type ServerConfig, | |
| 5 | type ServerConfigStreamEvent, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 8 | import * as ConfigProvider from "effect/ConfigProvider"; | |
| 3 | 9 | import * as Deferred from "effect/Deferred"; |
| 4 | 10 | import * as Duration from "effect/Duration"; |
| 5 | 11 | import * as Effect from "effect/Effect"; |
| 6 | 12 | import * as Fiber from "effect/Fiber"; |
| 13 | import * as FileSystem from "effect/FileSystem"; | |
| 14 | import * as Layer from "effect/Layer"; | |
| 15 | import * as Path from "effect/Path"; | |
| 16 | import * as Queue from "effect/Queue"; | |
| 17 | import * as Stream from "effect/Stream"; | |
| 7 | 18 | import * as TestClock from "effect/testing/TestClock"; |
| 19 | import { ChildProcessSpawner } from "effect/unstable/process"; | |
| 8 | 20 | |
| 21 | import * as ExternalLauncher from "./process/externalLauncher.ts"; | |
| 9 | 22 | import { |
| 10 | 23 | hasCompatibleOrchestrationProtocol, |
| 11 | 24 | resolveAvailableEditorsForConfig, |
| 12 | 25 | shouldUseBoundedThreadSnapshot, |
| 26 | withLateEditorConfig, | |
| 13 | 27 | } from "./ws.ts"; |
| 14 | 28 | |
| 15 | 29 | it("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 | 62 | assert.deepEqual(availableEditors, []); |
| 49 | 63 | }), |
| 50 | 64 | ); |
| 65 | ||
| 66 | // Only the fields the late-editor fold reads or rewrites. | |
| 67 | const snapshotConfig = (fields: Partial<ServerConfig>) => | |
| 68 | ({ availableEditors: [], settings: {}, ...fields }) as unknown as ServerConfig; | |
| 69 | ||
| 70 | const settingsUpdated = (settings: object): ServerConfigStreamEvent => ({ | |
| 71 | version: 1, | |
| 72 | type: "settingsUpdated", | |
| 73 | payload: { settings: settings as ServerConfig["settings"] }, | |
| 74 | }); | |
| 75 | ||
| 76 | it.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 | ||
| 107 | it.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 | ||
| 122 | it.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. | |
| 144 | const 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 | ||
| 186 | it.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 | ||
| 213 | it.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 | 76 | type RelayClientInstallProgressEvent, |
| 77 | 77 | type ServerSelfUpdateError, |
| 78 | 78 | type ServerSelfUpdateProgressEvent, |
| 79 | type ServerConfig as ClientServerConfig, | |
| 80 | type ServerConfigStreamEvent, | |
| 79 | 81 | type ServerLifecycleStreamEvent, |
| 80 | 82 | type FilesystemBrowseFailure, |
| 81 | 83 | FilesystemBrowseError, |
| ... | ... | @@ -261,6 +263,94 @@ const resolveFileManagerRevealKindForConfig = <E, R>( |
| 261 | 263 | discovery: Effect.Effect<FileManagerRevealKind | undefined, E, R>, |
| 262 | 264 | ) => resolveDiscoveryForConfig(discovery, () => undefined); |
| 263 | 265 | |
| 266 | type EditorDiscovery = Pick< | |
| 267 | ExternalLauncher.ExternalLauncher["Service"], | |
| 268 | "resolveAvailableEditors" | "resolveFileManagerRevealKind" | |
| 269 | >; | |
| 270 | ||
| 271 | // The config fields that follow from which editors are installed. | |
| 272 | const 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 | */ | |
| 299 | export 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 | ||
| 264 | 354 | function unexpectedCompatibilityError(error: never): never { |
| 265 | 355 | throw new Error(`Unhandled compatibility error: ${String(error)}`); |
| 266 | 356 | } |
| ... | ... | @@ -1620,14 +1710,10 @@ const makeWsRpcLayer = ( |
| 1620 | 1710 | const environment = yield* serverEnvironment.getDescriptor; |
| 1621 | 1711 | const auth = yield* serverAuth.getDescriptor(); |
| 1622 | 1712 | 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()), | |
| 1625 | 1716 | ); |
| 1626 | const fileManagerRevealKind = availableEditors.includes("file-manager") | |
| 1627 | ? yield* resolveFileManagerRevealKindForConfig( | |
| 1628 | externalLauncher.resolveFileManagerRevealKind(), | |
| 1629 | ) | |
| 1630 | : undefined; | |
| 1631 | 1717 | |
| 1632 | 1718 | return { |
| 1633 | 1719 | environment, |
| ... | ... | @@ -1637,7 +1723,7 @@ const makeWsRpcLayer = ( |
| 1637 | 1723 | keybindings: keybindingsConfig.keybindings, |
| 1638 | 1724 | issues: keybindingsConfig.issues, |
| 1639 | 1725 | providers, |
| 1640 | availableEditors, | |
| 1726 | ...editorConfig, | |
| 1641 | 1727 | // Same discovery-with-timeout treatment as editors: a slow probe |
| 1642 | 1728 | // must not stall server.getConfig, so it degrades to no targets. |
| 1643 | 1729 | remoteOpenTargets: yield* resolveAvailableEditorsForConfig( |
| ... | ... | @@ -1659,12 +1745,6 @@ const makeWsRpcLayer = ( |
| 1659 | 1745 | }, |
| 1660 | 1746 | settings, |
| 1661 | 1747 | shellResumeCompletionMarker: true, |
| 1662 | ...(fileManagerRevealKind === undefined | |
| 1663 | ? {} | |
| 1664 | : { | |
| 1665 | shellRevealInFileManager: true, | |
| 1666 | shellRevealInFileManagerKind: fileManagerRevealKind, | |
| 1667 | }), | |
| 1668 | 1748 | threadResumeCompletionMarker: true, |
| 1669 | 1749 | threadSnapshotPagination: true, |
| 1670 | 1750 | ...Option.match(scratchWorkspaceRoot, { |
| ... | ... | @@ -3632,7 +3712,7 @@ const makeWsRpcLayer = ( |
| 3632 | 3712 | |
| 3633 | 3713 | return Stream.concat( |
| 3634 | 3714 | rpcInitialItems([{ version: 1 as const, type: "snapshot" as const, config }]), |
| 3635 | liveUpdates, | |
| 3715 | withLateEditorConfig(config, liveUpdates, externalLauncher), | |
| 3636 | 3716 | ); |
| 3637 | 3717 | }), |
| 3638 | 3718 | { "rpc.aggregate": "server" }, |