authorgravatar for 269035359+t3-code[bot]@users.noreply.github.comt3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> 2026-09-02 17:13:46-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-02 17:13:46-04:00
logf90e2f2bd26e22b77ccf781cccdf95afd3c3ac1c
tree99803041c48bb4368ca465f601e9b14737c12c34
parentb57726ca842624b713572529109ac49b17c93fb3
signature Signed by PGP key B5690EEEBB952194

fix(server): subscribe before provider settings watcher (#9271)

Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> Co-authored-by: maria <254055478+maria-rcks@users.noreply.github.com>

2 files changed, 19 insertions(+), 6 deletions(-)

apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts+5-4
......@@ -31,7 +31,7 @@
3131 * 1. Read the current `ServerSettings` once and use it to seed the
3232 * registry's initial state via `ProviderInstanceRegistryMutableLayer`.
3333 * 2. Fork a daemon fiber (lifetime tied to the layer's scope) that
34 * subscribes to `ServerSettingsService.streamChanges` and calls
34 * acquires `ServerSettingsService.subscribeChanges` and calls
3535 * `ProviderInstanceRegistryMutator.reconcile` on every emission.
3636 *
3737 * Failures inside the watcher are logged and swallowed so a single bad
......@@ -118,7 +118,8 @@ const SettingsWatcherLive = Layer.effectDiscard(
118118 Effect.gen(function* () {
119119 const mutator = yield* ProviderInstanceRegistryMutator;
120120 const serverSettings = yield* ServerSettingsService;
121 yield* serverSettings.streamChanges.pipe(
121 const settingsChanges = yield* serverSettings.subscribeChanges;
122 yield* settingsChanges.pipe(
122123 Stream.runForEach((next) =>
123124 mutator
124125 .reconcile(deriveProviderInstanceConfigMap(next))
......@@ -141,8 +142,8 @@ const SettingsWatcherLive = Layer.effectDiscard(
141142 * - `ProviderInstanceRegistryMutableLayer` produces the registry +
142143 * mutator from the initial config map. Its scope owns every
143144 * per-instance child scope created during reconcile.
144 * - `SettingsWatcherLive` consumes the mutator and runs a daemon fiber
145 * in the same scope.
145 * - `SettingsWatcherLive` consumes the mutator, acquires its settings
146 * subscription before forking, and runs a daemon fiber in the same scope.
146147 *
147148 * Composing via `Layer.provideMerge` makes the watcher's deps available
148149 * from the mutable layer while still surfacing the registry as an output.
apps/server/src/provider/Layers/ProviderRegistry.test.ts+14-2
......@@ -1944,7 +1944,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
19441944 const firstMissing = `t3code_codex_first_`;
19451945 const secondMissing = `t3code_codex_second_`;
19461946 const spawnedCommands: Array<string> = [];
1947 const serverSettings = yield* makeMutableServerSettingsService(
1947 const allowLazySettingsStream = yield* Deferred.make<void>();
1948 const mutableServerSettings = yield* makeMutableServerSettingsService(
19481949 decodeServerSettings(
19491950 deepMerge(encodedDefaultServerSettings, {
19501951 providers: {
......@@ -1957,6 +1958,14 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
19571958 }),
19581959 ),
19591960 );
1961 const serverSettings = {
1962 ...mutableServerSettings,
1963 streamChanges: Stream.unwrap(
1964 Deferred.await(allowLazySettingsStream).pipe(
1965 Effect.as(mutableServerSettings.streamChanges),
1966 ),
1967 ),
1968 } satisfies ServerSettingsModule.ServerSettingsService["Service"];
19601969 const scope = yield* Scope.make();
19611970 yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));
19621971 const providerRegistryLayer = ProviderRegistryLive.pipe(
......@@ -2016,7 +2025,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
20162025 assert.deepStrictEqual(spawnedCommands, [firstMissing]);
20172026
20182027 // Drive a settings change. The Hydration layer's
2019 // `SettingsWatcherLive` consumes this via `streamChanges`,
2028 // `SettingsWatcherLive` consumes this via `subscribeChanges`,
20202029 // calls `reconcile`, which rebuilds the codex instance (the
20212030 // envelope changed because `binaryPath` differs → `entryEqual`
20222031 // is false). The registry's `Stream.runForEach(
......@@ -2028,6 +2037,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
20282037 codex: { enabled: true, binaryPath: secondMissing },
20292038 },
20302039 });
2040 // Start the lazy stream only after publishing. A watcher that did
2041 // not subscribe before forking has already lost this update.
2042 yield* Deferred.succeed(allowLazySettingsStream, undefined);
20312043
20322044 // Poll until the injected process boundary observes the new
20332045 // executable. This verifies the public settings-to-probe behavior