| 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 +6,7 @@ import * as NodePath from "node:path"; |
| 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 7 | import { assert, it } from "@effect/vitest"; | 7 | import { assert, it } from "@effect/vitest"; |
| 8 | import * as ConfigProvider from "effect/ConfigProvider"; | 8 | import * as ConfigProvider from "effect/ConfigProvider"; |
| 9 | import * as Deferred from "effect/Deferred"; | ||
| 9 | import * as Effect from "effect/Effect"; | 10 | import * as Effect from "effect/Effect"; |
| 10 | import * as Fiber from "effect/Fiber"; | 11 | import * as Fiber from "effect/Fiber"; |
| 11 | import * as FileSystem from "effect/FileSystem"; | 12 | import * 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 | }); |
| 1188 | 1189 | ||
| 1189 | // A client that disconnects mid-scan interrupts the shared discovery effect on | 1190 | // Connects run discovery under a timeout and may disconnect mid-scan. Neither |
| 1190 | // the connection fiber. The cache must not retain that interrupt: doing so | 1191 | // 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. | 1193 | it.effect("keeps scanning after the caller is interrupted and shares that scan", () => { |
| 1193 | it.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 while | 1201 | // Scans park inside `stat` until released, so the interrupt lands |
| 1202 | // discovery is in flight, which is what a client disconnecting | 1202 | // 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; |
| 1224 | 1223 | ||
| 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); |
| 1228 | 1227 | ||
| 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 { |
| 28 | import * as Clock from "effect/Clock"; | 28 | import * as Clock from "effect/Clock"; |
| 29 | import * as Config from "effect/Config"; | 29 | import * as Config from "effect/Config"; |
| 30 | import * as Context from "effect/Context"; | 30 | import * as Context from "effect/Context"; |
| 31 | import * as Deferred from "effect/Deferred"; | ||
| 31 | import * as Effect from "effect/Effect"; | 32 | import * as Effect from "effect/Effect"; |
| 32 | import * as Encoding from "effect/Encoding"; | 33 | import * as Encoding from "effect/Encoding"; |
| 34 | import * as Exit from "effect/Exit"; | ||
| 33 | import * as FileSystem from "effect/FileSystem"; | 35 | import * as FileSystem from "effect/FileSystem"; |
| 34 | import * as Layer from "effect/Layer"; | 36 | import * as Layer from "effect/Layer"; |
| 35 | import * as Option from "effect/Option"; | 37 | import * as Option from "effect/Option"; |
| 36 | import * as Path from "effect/Path"; | 38 | import * as Path from "effect/Path"; |
| 37 | import * as Ref from "effect/Ref"; | 39 | import * as Ref from "effect/Ref"; |
| 40 | import * as Scope from "effect/Scope"; | ||
| 38 | import * as Stream from "effect/Stream"; | 41 | import * as Stream from "effect/Stream"; |
| 39 | import * as ChildProcess from "effect/unstable/process/ChildProcess"; | 42 | import * as ChildProcess from "effect/unstable/process/ChildProcess"; |
| 40 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | 43 | import * 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 the | 465 | // 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 the | 468 | // 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 this | 469 | // 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 it | 471 | // 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 the | 473 | // 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 the | 474 | // Expiry uses the monotonic clock (Clock.currentTimeNanos), matching the |
| 473 | // command-resolution cache in @t3tools/shared/shell, so a backward wall-clock | 475 | // 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. |
| 475 | const EDITOR_DISCOVERY_CACHE_TTL_NANOS = 60_000_000_000n; | 477 | const EDITOR_DISCOVERY_CACHE_TTL_NANOS = 60_000_000_000n; |
| 476 | 478 | ||
| 477 | interface EditorDiscoveryCacheEntry { | 479 | interface 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 | } |
| 481 | 484 | ||
| 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 | ); |
| 762 | 765 | ||
| 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); | ||
| 784 | 817 | ||
| 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 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | 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 | import * as Deferred from "effect/Deferred"; | 9 | import * as Deferred from "effect/Deferred"; |
| 4 | import * as Duration from "effect/Duration"; | 10 | import * as Duration from "effect/Duration"; |
| 5 | import * as Effect from "effect/Effect"; | 11 | import * as Effect from "effect/Effect"; |
| 6 | import * as Fiber from "effect/Fiber"; | 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 | import * as TestClock from "effect/testing/TestClock"; | 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 | import { | 22 | import { |
| 10 | hasCompatibleOrchestrationProtocol, | 23 | hasCompatibleOrchestrationProtocol, |
| 11 | resolveAvailableEditorsForConfig, | 24 | resolveAvailableEditorsForConfig, |
| 12 | shouldUseBoundedThreadSnapshot, | 25 | shouldUseBoundedThreadSnapshot, |
| 26 | withLateEditorConfig, | ||
| 13 | } from "./ws.ts"; | 27 | } from "./ws.ts"; |
| 14 | 28 | ||
| 15 | it("accepts only the current orchestration protocol before websocket RPC setup", () => { | 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,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. | ||
| 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,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); |
| 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 | function unexpectedCompatibilityError(error: never): never { | 354 | function 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; | ||
| 1631 | 1717 | ||
| 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 probe | 1727 | // 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 = ( |
| 3632 | 3712 | ||
| 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" }, |