| 1 | // Pool registry for multiple backend processes. This file is the entry |
| 2 | // point for the concurrent-Windows+WSL-backend feature; see the design |
| 3 | // notes below before extending it. |
| 4 | // |
| 5 | // Current state: |
| 6 | // - `DesktopBackendManager.ts` exposes a per-instance factory |
| 7 | // (`makeBackendInstance(spec)`); the pool calls it once for the |
| 8 | // Windows primary at startup, and `DesktopWslBackend.reconcile` |
| 9 | // calls it through `pool.register` to bring up the WSL instance |
| 10 | // when the user enables it. |
| 11 | // - The primary spec wires `configResolve` to |
| 12 | // `DesktopBackendConfiguration.resolvePrimary` and the |
| 13 | // `onReady`/`onShutdown` callbacks to the window service. WSL |
| 14 | // instances wire `configResolve: configuration.resolveWsl(...)` |
| 15 | // and skip onReady/onShutdown — the window only follows the primary. |
| 16 | // - The pool exposes `register(spec)` and `unregister(id)`. Each |
| 17 | // registered instance gets its own child scope, so unregister can |
| 18 | // stop it cleanly without tearing down the pool. The primary's id |
| 19 | // refuses unregister. |
| 20 | // - Settings: `wslBackendEnabled: boolean` + `wslDistro: string | null`. |
| 21 | // The legacy `wslMode: "local" | "wsl"` swap setting is migrated on |
| 22 | // load. IPC surface is `setWslBackendEnabled(boolean)` + |
| 23 | // `setWslDistro(string | null)`; both persist and then call the |
| 24 | // orchestrator's reconcile. No swap, no rollback, primary stays up. |
| 25 | // - `getLocalEnvironmentBootstraps()` (plural) returns one entry per |
| 26 | // pool instance currently registered with bootstrap info. The |
| 27 | // primary keeps the "primary" id; WSL instances are "wsl:default" |
| 28 | // or "wsl:<distro>". |
| 29 | // - `pickFolder` accepts an optional `targetEnvironmentId`. Omitting |
| 30 | // it gives the Windows picker — what every existing caller gets, |
| 31 | // and what non-WSL users see. WSL targets route to the wsl helpers. |
| 32 | // - Web settings UX: a plain toggle for "WSL backend" plus a distro |
| 33 | // picker that shows up when the toggle is on. Default-off, so |
| 34 | // users who never opted in see the same surface as before. |
| 35 | // |
| 36 | // Renderer-side wiring (apps/web/src/environments/local/): |
| 37 | // - reconcileLocalSecondaryEnvironments() runs at app boot and after |
| 38 | // WSL settings changes. It reads getLocalEnvironmentBootstraps(), |
| 39 | // skips the primary (which the existing primary/ runtime owns), |
| 40 | // and for every other entry POSTs the shared bootstrap token to |
| 41 | // /api/auth/bootstrap/bearer on that backend's URL, fetches the |
| 42 | // descriptor, builds a SavedEnvironmentRecord marked desktopLocal, |
| 43 | // writes the bearer to the secret store, and opens a connection |
| 44 | // through the same saved-env path remote envs use. |
| 45 | // - The desktopLocal marker filters records out of saved-env |
| 46 | // persistence, so toggling WSL off or switching distros doesn't |
| 47 | // pollute the user's settings file. The sidebar, CommandPalette, |
| 48 | // env switcher, and project-id routing all read the saved-env |
| 49 | // registry, so the WSL backend shows up there without any |
| 50 | // per-surface changes. |
| 51 | // |
| 52 | // Browser validation (2026-05-17, dev:desktop with wslBackendEnabled=true, |
| 53 | // wslDistro="Ubuntu"): |
| 54 | // - Two backends listening on distinct loopback ports |
| 55 | // (server.log: 13773 primary, 13774 wsl). |
| 56 | // - Per-instance log files: server-child.log + server-child-wsl_Ubuntu.log. |
| 57 | // - Distinct environment ids reported by each backend's |
| 58 | // /.well-known/t3/environment (Windows vs Linux platform). |
| 59 | // - Renderer completes the bearer-token bootstrap against the WSL |
| 60 | // backend (POST /api/auth/bootstrap/bearer 200), obtains a |
| 61 | // ws-token (POST /api/auth/ws-token 200), and holds an |
| 62 | // ESTABLISHED WebSocket connection to both ports (netstat). |
| 63 | // |
| 64 | // Migration history (commits): |
| 65 | // 1. Reshape `DesktopBackendManager` into an instance factory and route |
| 66 | // consumers through the pool. Pool held a single instance. (a8fc7845) |
| 67 | // 2. Drop `DesktopState.backendReady`. The window owns its own |
| 68 | // readiness latch via onReady / onShutdown callbacks. (425c7d0b) |
| 69 | // 3. Per-instance log routing via DesktopBackendOutputLogFactory. (563820ed) |
| 70 | // 4. Add register/unregister to the pool. (a0eaf560) |
| 71 | // 5. Wire WSL through the pool: settings rename, BackendConfiguration |
| 72 | // split, DesktopWslBackend orchestrator, new IPC, web compat. |
| 73 | // (b1622191 + 31ce3add + 627c80cb) |
| 74 | // 6. Widen getLocalEnvironmentBootstrap to *Bootstraps (plural). (bad66041) |
| 75 | // 7. pickFolder takes optional targetEnvironmentId. (5d80468d) |
| 76 | // 8. Settings UX: toggle + distro picker, no swap dialog. (eb5a03ea) |
| 77 | // 9. Register WSL backend as desktop-local saved env via |
| 78 | // reconcileLocalSecondaryEnvironments. (1c7e7873 + c17897bd) |
| 79 | // 10. CommandPalette enables file-manager picker for desktop-local |
| 80 | // envs, routes pickFolder by env id. (38e8477a) |
| 81 | |
| 82 | import * as Context from "effect/Context"; |
| 83 | import * as Deferred from "effect/Deferred"; |
| 84 | import * as Effect from "effect/Effect"; |
| 85 | import * as Exit from "effect/Exit"; |
| 86 | import * as Layer from "effect/Layer"; |
| 87 | import * as Option from "effect/Option"; |
| 88 | import * as Schema from "effect/Schema"; |
| 89 | import * as Scope from "effect/Scope"; |
| 90 | import * as SynchronizedRef from "effect/SynchronizedRef"; |
| 91 | |
| 92 | import * as FileSystem from "effect/FileSystem"; |
| 93 | import { HttpClient } from "effect/http"; |
| 94 | import { ChildProcessSpawner } from "effect/process"; |
| 95 | |
| 96 | import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; |
| 97 | import * as DesktopBackendManager from "./DesktopBackendManager.ts"; |
| 98 | import * as DesktopObservability from "../app/DesktopObservability.ts"; |
| 99 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; |
| 100 | import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; |
| 101 | import * as DesktopTerminalHost from "./DesktopTerminalHost.ts"; |
| 102 | import * as DesktopBrowserHost from "../preview/DesktopBrowserHost.ts"; |
| 103 | import * as DesktopWindow from "../window/DesktopWindow.ts"; |
| 104 | import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; |
| 105 | import * as ElectronDialog from "../electron/ElectronDialog.ts"; |
| 106 | import * as ElectronApp from "../electron/ElectronApp.ts"; |
| 107 | |
| 108 | const { logWarning: logBackendPoolWarning } = |
| 109 | DesktopObservability.makeComponentLogger("desktop-backend-pool"); |
| 110 | |
| 111 | export type BackendInstanceId = DesktopBackendManager.BackendInstanceId; |
| 112 | export const BackendInstanceId = DesktopBackendManager.BackendInstanceId; |
| 113 | export const PRIMARY_INSTANCE_ID = DesktopBackendManager.PRIMARY_INSTANCE_ID; |
| 114 | export type DesktopBackendInstance = DesktopBackendManager.DesktopBackendInstance; |
| 115 | export type BackendInstanceSpec = DesktopBackendManager.BackendInstanceSpec; |
| 116 | |
| 117 | // Caller tried to register an id that's already in the pool. The pool |
| 118 | // refuses overwrites so two independent orchestrators racing on the |
| 119 | // same id surface as a typed failure instead of one silently winning. |
| 120 | export class DesktopBackendPoolInstanceAlreadyRegisteredError extends Schema.TaggedError<DesktopBackendPoolInstanceAlreadyRegisteredError>()( |
| 121 | "DesktopBackendPoolInstanceAlreadyRegisteredError", |
| 122 | { |
| 123 | id: Schema.String, |
| 124 | }, |
| 125 | ) { |
| 126 | override get message() { |
| 127 | return `Backend instance "${this.id}" is already registered in the pool.`; |
| 128 | } |
| 129 | } |
| 130 | |
| 131 | // Primary instance is registered for the pool's lifetime. Unregister is |
| 132 | // a no-op for it today (no real callers), but if someone wires it up |
| 133 | // later it's a clear bug rather than something to "handle". |
| 134 | export class DesktopBackendPoolCannotUnregisterPrimaryError extends Schema.TaggedError<DesktopBackendPoolCannotUnregisterPrimaryError>()( |
| 135 | "DesktopBackendPoolCannotUnregisterPrimaryError", |
| 136 | {}, |
| 137 | ) { |
| 138 | override get message() { |
| 139 | return "Refusing to unregister the primary backend from the pool."; |
| 140 | } |
| 141 | } |
| 142 | |
| 143 | export class DesktopBackendPool extends Context.Service< |
| 144 | DesktopBackendPool, |
| 145 | { |
| 146 | // Look up a registered instance. None when no backend with that id is |
| 147 | // currently registered (e.g. WSL backend disabled). |
| 148 | readonly get: (id: BackendInstanceId) => Effect.Effect<Option.Option<DesktopBackendInstance>>; |
| 149 | // Snapshot of all currently-registered instances. Order is unspecified; |
| 150 | // callers that need a canonical "primary first" view should sort by id. |
| 151 | readonly list: Effect.Effect<readonly DesktopBackendInstance[]>; |
| 152 | // Convenience accessor for the always-registered primary instance. |
| 153 | // Currently equivalent to `get(PRIMARY_INSTANCE_ID)` unwrapped, but |
| 154 | // exposed as a typed effect so consumers don't have to handle the |
| 155 | // Option for the case that's guaranteed to be present. |
| 156 | readonly primary: Effect.Effect<DesktopBackendInstance>; |
| 157 | // Build a fresh DesktopBackendInstance from `spec` and add it to the |
| 158 | // registry. The pool owns the instance's scope: unregister(id) or pool |
| 159 | // teardown closes it and runs the instance's auto-stop finalizer. The |
| 160 | // returned instance has not been started — callers decide when to |
| 161 | // start it (and can call start more than once if a retry-after-failure |
| 162 | // story makes sense for them). |
| 163 | readonly register: ( |
| 164 | spec: BackendInstanceSpec, |
| 165 | ) => Effect.Effect<DesktopBackendInstance, DesktopBackendPoolInstanceAlreadyRegisteredError>; |
| 166 | // Stop the named instance and remove it from the registry. Closing the |
| 167 | // instance's scope triggers its auto-stop finalizer; the registry is |
| 168 | // updated atomically with the scope close so subsequent get(id) calls |
| 169 | // observe the unregister before the underlying child process has fully |
| 170 | // exited. |
| 171 | readonly unregister: ( |
| 172 | id: BackendInstanceId, |
| 173 | ) => Effect.Effect<void, DesktopBackendPoolCannotUnregisterPrimaryError>; |
| 174 | } |
| 175 | >()("@t3tools/desktop/backend/DesktopBackendPool") {} |
| 176 | |
| 177 | // Services required by makeBackendInstance — exported so caller |
| 178 | // orchestrators that build their own specs can confirm the layer graph |
| 179 | // satisfies them at compile time. |
| 180 | export type BackendInstanceFactoryRequirements = |
| 181 | | FileSystem.FileSystem |
| 182 | | ChildProcessSpawner.ChildProcessSpawner |
| 183 | | HttpClient.HttpClient |
| 184 | | DesktopObservability.DesktopBackendOutputLogFactory |
| 185 | | DesktopTelemetryPublisher.DesktopTelemetryPublisher |
| 186 | | DesktopTerminalHost.DesktopTerminalHost |
| 187 | | DesktopBrowserHost.DesktopBrowserHost |
| 188 | | DesktopWslEnvironment.DesktopWslEnvironment; |
| 189 | |
| 190 | interface ActiveRegisteredInstance { |
| 191 | readonly _tag: "Active"; |
| 192 | readonly instance: DesktopBackendInstance; |
| 193 | // None for the primary (which lives in the pool's own layer scope and |
| 194 | // is never unregistered); Some for instances added via register, whose |
| 195 | // scope unregister closes to stop them. |
| 196 | readonly scope: Option.Option<Scope.Closeable>; |
| 197 | } |
| 198 | |
| 199 | interface ClosingRegisteredInstance { |
| 200 | readonly _tag: "Closing"; |
| 201 | readonly done: Deferred.Deferred<void>; |
| 202 | } |
| 203 | |
| 204 | type RegisteredInstance = ActiveRegisteredInstance | ClosingRegisteredInstance; |
| 205 | |
| 206 | type RegisterAction = |
| 207 | | { readonly _tag: "Registered"; readonly instance: DesktopBackendInstance } |
| 208 | | { readonly _tag: "Wait"; readonly done: Deferred.Deferred<void> }; |
| 209 | |
| 210 | type UnregisterAction = |
| 211 | | { readonly _tag: "Absent" } |
| 212 | | { readonly _tag: "Wait"; readonly done: Deferred.Deferred<void> } |
| 213 | | { readonly _tag: "Close"; readonly entry: ActiveRegisteredInstance }; |
| 214 | |
| 215 | export const layer = Layer.effect( |
| 216 | DesktopBackendPool, |
| 217 | Effect.gen(function* () { |
| 218 | const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; |
| 219 | const desktopWindow = yield* DesktopWindow.DesktopWindow; |
| 220 | const electronDialog = yield* ElectronDialog.ElectronDialog; |
| 221 | const electronApp = yield* ElectronApp.ElectronApp; |
| 222 | const appSettings = yield* DesktopAppSettings.DesktopAppSettings; |
| 223 | // Anchor the pool's lifetime to its layer scope so registered |
| 224 | // instance scopes can be forked off it. Without this, instance |
| 225 | // scopes are orphaned: they only close via explicit unregister() |
| 226 | // calls, so on app shutdown the WSL backend child process gets |
| 227 | // hard-killed by the OS instead of receiving the graceful |
| 228 | // SIGTERM + grace period the instance's stop finalizer would |
| 229 | // otherwise run. |
| 230 | const layerScope = yield* Scope.Scope; |
| 231 | // Capture the services needed to build any future instance from the |
| 232 | // pool's layer scope. register() runs `makeBackendInstance` against |
| 233 | // a fresh child scope but reuses these services so the instance gets |
| 234 | // the same FileSystem, spawner, HTTP client and log factory the |
| 235 | // primary instance uses. |
| 236 | const factoryContext = yield* Effect.context<BackendInstanceFactoryRequirements>(); |
| 237 | |
| 238 | // A WSL preflight failure on the primary only happens in wsl-only mode. |
| 239 | // Fatal configuration failures persist the Windows fallback. Bounded |
| 240 | // transport failures use an in-memory fallback for this launch so the app |
| 241 | // opens without overwriting the user's WSL preference. |
| 242 | const handlePrimaryPreflightFailure = Effect.fn("desktop.backendPool.primaryPreflightFailed")( |
| 243 | function* (failure: DesktopBackendManager.PreflightFailure) { |
| 244 | const { reason, fatal } = failure; |
| 245 | if (!fatal) { |
| 246 | yield* logBackendPoolWarning( |
| 247 | "primary WSL preflight retry window exhausted; using Windows for this launch", |
| 248 | { reason }, |
| 249 | ); |
| 250 | yield* electronDialog.showErrorBox( |
| 251 | "WSL backend is still unavailable", |
| 252 | `${reason}\n\nThe Knife will use the Windows backend for this launch and retry WSL the next time the app starts.`, |
| 253 | ); |
| 254 | yield* appSettings.applyWslWindowsFallbackInMemory; |
| 255 | return true; |
| 256 | } |
| 257 | |
| 258 | yield* logBackendPoolWarning("primary WSL preflight failed; falling back to Windows", { |
| 259 | reason, |
| 260 | }); |
| 261 | yield* electronDialog.showErrorBox( |
| 262 | "WSL backend couldn't start", |
| 263 | `${reason}\n\nFalling back to the Windows backend so The Knife can open. Re-enable the WSL backend from Settings > Connections once the WSL distro is fixed.`, |
| 264 | ); |
| 265 | // Fully disable the WSL backend — both flags, matching the "Switch to |
| 266 | // Windows" recovery path — so the manager's next restart re-resolves the |
| 267 | // primary as Windows and reconcile won't register a secondary WSL backend |
| 268 | // against the same broken setup. Clearing wslBackendEnabled alone would |
| 269 | // leave a stale wslOnly:true that silently re-traps the user in wsl-only |
| 270 | // mode the next time they enable WSL. If the persisted write fails, keep |
| 271 | // this process recoverable by applying the fallback to in-memory settings. |
| 272 | yield* appSettings.applyWslWindowsFallback.pipe( |
| 273 | Effect.catch((error) => |
| 274 | logBackendPoolWarning( |
| 275 | "failed to persist Windows fallback after WSL preflight failure", |
| 276 | { |
| 277 | error: error.message, |
| 278 | }, |
| 279 | ).pipe(Effect.andThen(appSettings.applyWslWindowsFallbackInMemory)), |
| 280 | ), |
| 281 | ); |
| 282 | return true; |
| 283 | }, |
| 284 | ); |
| 285 | |
| 286 | const primary = yield* DesktopBackendManager.makeBackendInstance({ |
| 287 | id: DesktopBackendManager.PRIMARY_INSTANCE_ID, |
| 288 | // Keep this lazy. The pool layer is initialized before startup loads |
| 289 | // persisted desktop settings, so resolving the primary label here would |
| 290 | // permanently capture DEFAULT_DESKTOP_SETTINGS and mislabel WSL-only |
| 291 | // primaries as Windows. |
| 292 | label: configuration.resolvePrimaryLabel, |
| 293 | configResolve: configuration.resolvePrimary, |
| 294 | // Window creation errors propagating out of handleBackendReady must |
| 295 | // not block the readiness callback (that would prevent restartAttempt |
| 296 | // from being reset), so we absorb them here. The window service only |
| 297 | // logs on success, so log the failure here before swallowing it — |
| 298 | // otherwise a post-readiness window-open failure vanishes silently and |
| 299 | // is near-impossible to diagnose in production. |
| 300 | onReady: (httpBaseUrl) => |
| 301 | desktopWindow.handleBackendReady(httpBaseUrl).pipe( |
| 302 | Effect.catch((error) => |
| 303 | logBackendPoolWarning("failed to open main window after backend readiness", { |
| 304 | error: error.message, |
| 305 | }), |
| 306 | ), |
| 307 | ), |
| 308 | onShutdown: () => desktopWindow.handleBackendNotReady, |
| 309 | onPreflightFailed: handlePrimaryPreflightFailure, |
| 310 | onStateDirOwned: () => |
| 311 | electronDialog |
| 312 | .showErrorBox( |
| 313 | "This T3 home is unavailable", |
| 314 | "Another The Knife server, such as the background service, is using this data directory. Stop that server and reopen The Knife, or set a separate T3CODE_HOME and pair with the running server.", |
| 315 | ) |
| 316 | .pipe(Effect.andThen(electronApp.quit)), |
| 317 | }); |
| 318 | |
| 319 | const instancesRef = yield* SynchronizedRef.make< |
| 320 | ReadonlyMap<BackendInstanceId, RegisteredInstance> |
| 321 | >( |
| 322 | new Map([ |
| 323 | [ |
| 324 | DesktopBackendManager.PRIMARY_INSTANCE_ID, |
| 325 | { _tag: "Active", instance: primary, scope: Option.none() }, |
| 326 | ], |
| 327 | ]), |
| 328 | ); |
| 329 | |
| 330 | const register: DesktopBackendPool["Service"]["register"] = (spec) => |
| 331 | Effect.suspend(() => |
| 332 | SynchronizedRef.modifyEffect( |
| 333 | instancesRef, |
| 334 | ( |
| 335 | current, |
| 336 | ): Effect.Effect< |
| 337 | readonly [RegisterAction, ReadonlyMap<BackendInstanceId, RegisteredInstance>], |
| 338 | DesktopBackendPoolInstanceAlreadyRegisteredError |
| 339 | > => { |
| 340 | const existing = current.get(spec.id); |
| 341 | if (existing?._tag === "Active") { |
| 342 | return Effect.fail( |
| 343 | new DesktopBackendPoolInstanceAlreadyRegisteredError({ id: spec.id }), |
| 344 | ); |
| 345 | } |
| 346 | if (existing?._tag === "Closing") { |
| 347 | return Effect.succeed([ |
| 348 | { _tag: "Wait", done: existing.done } as const, |
| 349 | current, |
| 350 | ] as const); |
| 351 | } |
| 352 | return Effect.gen(function* () { |
| 353 | // Provide the captured factory services first, then the child scope |
| 354 | // last so instance finalizers are owned by the unregisterable scope. |
| 355 | const instanceScope = yield* Scope.fork(layerScope, "sequential"); |
| 356 | const instance = yield* DesktopBackendManager.makeBackendInstance(spec).pipe( |
| 357 | Effect.provide(factoryContext), |
| 358 | Scope.provide(instanceScope), |
| 359 | ); |
| 360 | const next = new Map(current); |
| 361 | next.set(spec.id, { |
| 362 | _tag: "Active", |
| 363 | instance, |
| 364 | scope: Option.some(instanceScope), |
| 365 | }); |
| 366 | return [ |
| 367 | { _tag: "Registered", instance } as const, |
| 368 | next as ReadonlyMap<BackendInstanceId, RegisteredInstance>, |
| 369 | ] as const; |
| 370 | }); |
| 371 | }, |
| 372 | ).pipe( |
| 373 | Effect.flatMap((result) => |
| 374 | result._tag === "Registered" |
| 375 | ? Effect.succeed(result.instance) |
| 376 | : Deferred.await(result.done).pipe(Effect.andThen(register(spec))), |
| 377 | ), |
| 378 | ), |
| 379 | ); |
| 380 | |
| 381 | const unregister: DesktopBackendPool["Service"]["unregister"] = (id) => |
| 382 | Effect.gen(function* () { |
| 383 | if (id === DesktopBackendManager.PRIMARY_INSTANCE_ID) { |
| 384 | return yield* new DesktopBackendPoolCannotUnregisterPrimaryError(); |
| 385 | } |
| 386 | const done = yield* Deferred.make<void>(); |
| 387 | const action = yield* SynchronizedRef.modifyEffect( |
| 388 | instancesRef, |
| 389 | ( |
| 390 | current, |
| 391 | ): Effect.Effect< |
| 392 | readonly [UnregisterAction, ReadonlyMap<BackendInstanceId, RegisteredInstance>] |
| 393 | > => { |
| 394 | const entry = current.get(id); |
| 395 | if (entry === undefined) { |
| 396 | return Effect.succeed([{ _tag: "Absent" } as const, current] as const); |
| 397 | } |
| 398 | if (entry._tag === "Closing") { |
| 399 | return Effect.succeed([ |
| 400 | { _tag: "Wait", done: entry.done } as const, |
| 401 | current, |
| 402 | ] as const); |
| 403 | } |
| 404 | const next = new Map(current); |
| 405 | next.set(id, { _tag: "Closing", done }); |
| 406 | return Effect.succeed([ |
| 407 | { _tag: "Close", entry } as const, |
| 408 | next as ReadonlyMap<BackendInstanceId, RegisteredInstance>, |
| 409 | ] as const); |
| 410 | }, |
| 411 | ); |
| 412 | |
| 413 | if (action._tag === "Absent") return; |
| 414 | if (action._tag === "Wait") { |
| 415 | yield* Deferred.await(action.done); |
| 416 | return; |
| 417 | } |
| 418 | |
| 419 | const finish = SynchronizedRef.modifyEffect(instancesRef, (current) => { |
| 420 | const closing = current.get(id); |
| 421 | if (closing?._tag !== "Closing" || closing.done !== done) { |
| 422 | return Effect.succeed([undefined, current] as const); |
| 423 | } |
| 424 | const next = new Map(current); |
| 425 | next.delete(id); |
| 426 | return Effect.succeed([ |
| 427 | undefined, |
| 428 | next as ReadonlyMap<BackendInstanceId, RegisteredInstance>, |
| 429 | ] as const); |
| 430 | }).pipe(Effect.andThen(Deferred.succeed(done, undefined)), Effect.asVoid); |
| 431 | yield* Option.match(action.entry.scope, { |
| 432 | onNone: () => Effect.void, |
| 433 | onSome: (scope) => Scope.close(scope, Exit.void).pipe(Effect.ignore), |
| 434 | }).pipe(Effect.ensuring(finish)); |
| 435 | }); |
| 436 | |
| 437 | return DesktopBackendPool.of({ |
| 438 | get: (id) => |
| 439 | SynchronizedRef.get(instancesRef).pipe( |
| 440 | Effect.map((instances) => { |
| 441 | const entry = instances.get(id); |
| 442 | return entry?._tag === "Active" ? Option.some(entry.instance) : Option.none(); |
| 443 | }), |
| 444 | ), |
| 445 | list: SynchronizedRef.get(instancesRef).pipe( |
| 446 | Effect.map((instances) => |
| 447 | Array.from(instances.values()).flatMap((entry) => |
| 448 | entry._tag === "Active" ? [entry.instance] : [], |
| 449 | ), |
| 450 | ), |
| 451 | ), |
| 452 | primary: Effect.succeed(primary), |
| 453 | register, |
| 454 | unregister, |
| 455 | }); |
| 456 | }), |
| 457 | ); |
| 458 | |
| 459 | // Test layer for unit tests that want to assert against a known pool |
| 460 | // composition without standing up the full manager. Each provided |
| 461 | // instance is registered under its own id; the first one is also |
| 462 | // surfaced as `primary` so callers can stub a single-instance pool. |
| 463 | // `register` and `unregister` are stubbed to die so tests that |
| 464 | // accidentally exercise pool registration fail loudly instead of |
| 465 | // silently noop'ing. |
| 466 | export const layerTest = ( |
| 467 | instances: readonly DesktopBackendInstance[], |
| 468 | ): Layer.Layer<DesktopBackendPool> => |
| 469 | Layer.effect( |
| 470 | DesktopBackendPool, |
| 471 | Effect.gen(function* () { |
| 472 | if (instances.length === 0) { |
| 473 | return yield* Effect.die("DesktopBackendPool.layerTest requires at least one instance"); |
| 474 | } |
| 475 | const byId = new Map<BackendInstanceId, DesktopBackendInstance>( |
| 476 | instances.map((instance) => [instance.id, instance] as const), |
| 477 | ); |
| 478 | const primary = instances[0]!; |
| 479 | return DesktopBackendPool.of({ |
| 480 | get: (id) => Effect.succeed(Option.fromNullishOr(byId.get(id))), |
| 481 | list: Effect.succeed(Array.from(byId.values())), |
| 482 | primary: Effect.succeed(primary), |
| 483 | register: () => Effect.die("DesktopBackendPool.layerTest does not support register"), |
| 484 | unregister: () => Effect.die("DesktopBackendPool.layerTest does not support unregister"), |
| 485 | }); |
| 486 | }), |
| 487 | ); |