authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-22 23:25:35-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-22 23:25:35-07:00
logafa83098064e7dca524a1e42dea3de03a883a0b6
treeaefca47eaf5fb70aafe8230f5f1156ddc0486e32
parentfdd1572b69537c95e31b45fdfafe88bbe964807a
signature Signed by PGP key B5690EEEBB952194

fix(clients): reconnect after credentials fail during remote server updates (#7953)


2 files changed, 40 insertions(+), 2 deletions(-)

packages/client-runtime/src/state/server.test.ts+24
...@@ -124,6 +124,30 @@ describe("update restart reconnect nudges", () => {...@@ -124,6 +124,30 @@ describe("update restart reconnect nudges", () => {
124 yield* Fiber.join(fiber);124 yield* Fiber.join(fiber);
125 }).pipe(Effect.provide(TestClock.layer())),125 }).pipe(Effect.provide(TestClock.layer())),
126 );126 );
127
128 it.effect("retries rejected credentials only while the update restart is in progress", () =>
129 Effect.gen(function* () {
130 const retries = yield* Ref.make(0);
131
132 yield* nudgeReconnectDuringUpdateRestart({
133 stateChanges: Stream.fromIterable([
134 { phase: "blocked", lastFailure: { reason: "permission" } },
135 {
136 phase: "blocked",
137 lastFailure: {
138 reason: "authentication",
139 detail: "The environment credential is invalid.",
140 },
141 },
142 { phase: "blocked", lastFailure: { reason: "configuration" } },
143 ]),
144 retryNow: Ref.update(retries, (count) => count + 1),
145 interval: Duration.zero,
146 });
147
148 expect(yield* Ref.get(retries)).toBe(1);
149 }),
150 );
127});151});
128152
129describe("server state projection", () => {153describe("server state projection", () => {
packages/client-runtime/src/state/server.ts+16-2
...@@ -160,16 +160,30 @@ export function validateServerUpdateReadyEvent(...@@ -160,16 +160,30 @@ export function validateServerUpdateReadyEvent(
160 * each nudge is the pacer: a connection that fails instantly re-enters backoff160 * each nudge is the pacer: a connection that fails instantly re-enters backoff
161 * immediately and would otherwise spin a tight retry loop.161 * immediately and would otherwise spin a tight retry loop.
162 *162 *
163 * A newly restarted server can also reject the first environment credential.
164 * Authentication blocks need the same paced retry during this known restart;
165 * permission and configuration failures remain blocked.
166 *
163 * Callers fork this as a child of the update command so it is interrupted as167 * Callers fork this as a child of the update command so it is interrupted as
164 * soon as the update settles, whether it succeeds, fails, or times out.168 * soon as the update settles, whether it succeeds, fails, or times out.
165 */169 */
166export function nudgeReconnectDuringUpdateRestart(input: {170export function nudgeReconnectDuringUpdateRestart(input: {
167 readonly stateChanges: Stream.Stream<{ readonly phase: string }, unknown>;171 readonly stateChanges: Stream.Stream<
172 {
173 readonly phase: string;
174 readonly lastFailure?: { readonly reason: string } | null;
175 },
176 unknown
177 >;
168 readonly retryNow: Effect.Effect<void>;178 readonly retryNow: Effect.Effect<void>;
169 readonly interval?: Duration.Duration;179 readonly interval?: Duration.Duration;
170}): Effect.Effect<void> {180}): Effect.Effect<void> {
171 return input.stateChanges.pipe(181 return input.stateChanges.pipe(
172 Stream.filter((state) => state.phase === "backoff"),182 Stream.filter(
183 (state) =>
184 state.phase === "backoff" ||
185 (state.phase === "blocked" && state.lastFailure?.reason === "authentication"),
186 ),
173 Stream.runForEach(() =>187 Stream.runForEach(() =>
174 Effect.sleep(input.interval ?? Duration.seconds(1)).pipe(Effect.andThen(input.retryNow)),188 Effect.sleep(input.interval ?? Duration.seconds(1)).pipe(Effect.andThen(input.retryNow)),
175 ),189 ),