diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index 821e244fd544f7bc9c7de801a74646aefd51466e..18d1f062f04394668f61a0dc9ba719ac08aba154 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -1410,6 +1410,48 @@ describe("EnvironmentRegistry", () => { }), ); + // The desktop rotates the bootstrap token, so the renderer exchanges it again + // and the server revokes the old bearer while the socket stays open. HTTP + // reads such as the session's scopes must move to the new bearer (#13324). + it.effect("a replaced platform bearer reaches a connected session's HTTP requests", () => + Effect.gen(function* () { + const harness = yield* makeHarness([]); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + const registration = (token: string) => + new BearerConnectionRegistration({ + target: BEARER_TARGET, + profile: BEARER_PROFILE, + credential: new BearerConnectionCredential({ token }), + }); + const preparedToken = registry.run( + BEARER_TARGET.environmentId, + EnvironmentSupervisor.EnvironmentSupervisor.pipe( + Effect.flatMap((supervisor) => SubscriptionRef.get(supervisor.prepared)), + Effect.map((prepared) => + Option.isSome(prepared) && prepared.value.httpAuthorization?._tag === "Bearer" + ? prepared.value.httpAuthorization.token + : null, + ), + ), + ); + yield* registry.reconcilePlatform([registration("old-bearer")]); + yield* awaitConnectionState( + registry, + BEARER_TARGET.environmentId, + (state) => state.phase === "connected", + ); + expect(yield* preparedToken).toBe("old-bearer"); + + yield* registry.reconcilePlatform([registration("new-bearer")]); + + expect(yield* preparedToken).toBe("new-bearer"); + expect(yield* Ref.get(harness.sessions)).toHaveLength(1); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + it.effect("a bearer replaced while an attempt is in flight retries with the new one", () => Effect.gen(function* () { const attemptStarted = yield* Deferred.make(); diff --git a/packages/client-runtime/src/connection/registry.ts b/packages/client-runtime/src/connection/registry.ts index caead65758df899ed3976bc648ceeb0129721e93..501a19bb0b9e0be7533bcdfc0746a5f41b1b6ef9 100644 --- a/packages/client-runtime/src/connection/registry.ts +++ b/packages/client-runtime/src/connection/registry.ts @@ -47,6 +47,7 @@ import { RELAY_ROUTE_ID, connectionRouteId, connectionRoutes, + credentialConnectionId, entryWithRoutes, findRouteToSameAddress, mergeLearnedRoutes, @@ -712,8 +713,10 @@ export const make = Effect.gen(function* () { // the equivalent runtime. Anything not yet connected may be using the // old bearer: an attempt in flight would fail with it and then wait // blocked for a manual retry. Retry with the new one instead. A - // connected session keeps its socket. - if (bearerReplaced) { + // connected session keeps its socket, but the server revoked the old + // bearer, so its HTTP requests (such as the session's scopes) move to + // the new one. + if (bearerReplaced && registration._tag === "BearerConnectionRegistration") { const scope = (yield* SubscriptionRef.get(serviceScopes)).get(target.environmentId); if (scope !== undefined) { const state = yield* SubscriptionRef.get(scope.supervisor.state); @@ -725,6 +728,23 @@ export const make = Effect.gen(function* () { state.lastFailure.reason === "authentication") ) { yield* scope.supervisor.retryNow; + } else if (state.phase === "connected") { + const token = registration.credential.token; + yield* SubscriptionRef.updateSome(scope.supervisor.prepared, (current) => + Option.isSome(current) && + current.value.target._tag === "BearerConnectionTarget" && + credentialConnectionId(current.value.target.connectionId) === + registration.target.connectionId && + current.value.httpAuthorization?._tag === "Bearer" && + current.value.httpAuthorization.token !== token + ? Option.some( + Option.some({ + ...current.value, + httpAuthorization: { _tag: "Bearer", token }, + }), + ) + : Option.none(), + ); } } }