| ... | @@ -19,6 +19,7 @@ import { | ... | @@ -19,6 +19,7 @@ import { |
| 19 | } from "@t3tools/contracts"; | 19 | } from "@t3tools/contracts"; |
| 20 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | 20 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 21 | import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell"; | 21 | import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell"; |
| | 22 | import * as Clock from "effect/Clock"; |
| 22 | import * as Config from "effect/Config"; | 23 | import * as Config from "effect/Config"; |
| 23 | import * as Context from "effect/Context"; | 24 | import * as Context from "effect/Context"; |
| 24 | import * as Effect from "effect/Effect"; | 25 | import * as Effect from "effect/Effect"; |
| ... | @@ -27,6 +28,7 @@ import * as FileSystem from "effect/FileSystem"; | ... | @@ -27,6 +28,7 @@ import * as FileSystem from "effect/FileSystem"; |
| 27 | import * as Layer from "effect/Layer"; | 28 | import * as Layer from "effect/Layer"; |
| 28 | import * as Option from "effect/Option"; | 29 | import * as Option from "effect/Option"; |
| 29 | import * as Path from "effect/Path"; | 30 | import * as Path from "effect/Path"; |
| | 31 | import * as Ref from "effect/Ref"; |
| 30 | import * as ChildProcess from "effect/unstable/process/ChildProcess"; | 32 | import * as ChildProcess from "effect/unstable/process/ChildProcess"; |
| 31 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | 33 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; |
| 32 | | 34 | |
| ... | @@ -302,7 +304,23 @@ const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEdit | ... | @@ -302,7 +304,23 @@ const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEdit |
| 302 | // client connect (the server config embeds the available editors). Memoize | 304 | // client connect (the server config embeds the available editors). Memoize |
| 303 | // the discovered set for a bounded window so repeat connects skip even the | 305 | // the discovered set for a bounded window so repeat connects skip even the |
| 304 | // per-command cache lookups in @t3tools/shared/shell. | 306 | // per-command cache lookups in @t3tools/shared/shell. |
| 305 | const EDITOR_DISCOVERY_CACHE_TTL = "60 seconds"; | 307 | // |
| | 308 | // This deliberately does not use `Effect.cachedWithTTL`: that memoizes the |
| | 309 | // first caller's Exit whatever it is, including an interrupt. Callers run this |
| | 310 | // on the connection fiber under a timeout (`resolveAvailableEditorsForConfig`), |
| | 311 | // so one client disconnecting mid-scan would cache the interrupt and replay it |
| | 312 | // to every later connect for the whole TTL, breaking `server.getConfig` |
| | 313 | // permanently. Storing only on success means an interrupted scan leaves the |
| | 314 | // cache untouched and the next connect simply rescans. |
| | 315 | // Expiry uses the monotonic clock (Clock.currentTimeNanos), matching the |
| | 316 | // command-resolution cache in @t3tools/shared/shell, so a backward wall-clock |
| | 317 | // adjustment cannot keep an expired entry alive. |
| | 318 | const EDITOR_DISCOVERY_CACHE_TTL_NANOS = 60_000_000_000n; |
| | 319 | |
| | 320 | interface EditorDiscoveryCacheEntry { |
| | 321 | readonly editors: ReadonlyArray<EditorId>; |
| | 322 | readonly expiresAtNanos: bigint; |
| | 323 | } |
| 306 | | 324 | |
| 307 | /** | 325 | /** |
| 308 | * ExternalLauncher - Service tag for browser/editor launch operations. | 326 | * ExternalLauncher - Service tag for browser/editor launch operations. |
| ... | @@ -449,10 +467,25 @@ export const make = Effect.gen(function* () { | ... | @@ -449,10 +467,25 @@ export const make = Effect.gen(function* () { |
| 449 | Effect.provideService(Path.Path, path), | 467 | Effect.provideService(Path.Path, path), |
| 450 | ); | 468 | ); |
| 451 | | 469 | |
| 452 | const cachedAvailableEditors = yield* Effect.cachedWithTTL( | 470 | const editorDiscoveryCache = yield* Ref.make<Option.Option<EditorDiscoveryCacheEntry>>( |
| 453 | provideCommandResolutionServices(resolveAvailableEditors()), | 471 | Option.none(), |
| 454 | EDITOR_DISCOVERY_CACHE_TTL, | | |
| 455 | ); | 472 | ); |
| | 473 | const cachedAvailableEditors = Effect.gen(function* () { |
| | 474 | const nowNanos = yield* Clock.currentTimeNanos; |
| | 475 | const entry = yield* Ref.get(editorDiscoveryCache); |
| | 476 | if (Option.isSome(entry) && entry.value.expiresAtNanos > nowNanos) { |
| | 477 | return entry.value.editors; |
| | 478 | } |
| | 479 | const editors = yield* provideCommandResolutionServices(resolveAvailableEditors()); |
| | 480 | yield* Ref.set( |
| | 481 | editorDiscoveryCache, |
| | 482 | Option.some({ |
| | 483 | editors, |
| | 484 | expiresAtNanos: nowNanos + EDITOR_DISCOVERY_CACHE_TTL_NANOS, |
| | 485 | }), |
| | 486 | ); |
| | 487 | return editors; |
| | 488 | }); |
| 456 | | 489 | |
| 457 | return ExternalLauncher.of({ | 490 | return ExternalLauncher.of({ |
| 458 | resolveAvailableEditors: () => cachedAvailableEditors, | 491 | resolveAvailableEditors: () => cachedAvailableEditors, |