Skip to content

Commit 6289f48

Browse files
committed
Reset primary env state and re-auth on backend swap
When the desktop swaps backends (Windows / WSL), each backend has a separate environment-id, separate sqlite, and separate session-signing key. Without explicit handling, the renderer kept the previous backend's threads/projects in the store and sent the previous backend's session cookie, which the new backend rejected with 401 — leaving the UI showing stale data and the WS connection unable to upgrade. - store: add a clearPrimaryEnvironmentState action that drops the previous env's slice from environmentStateById on identity change. Unset activeEnvironmentId if it pointed at the cleared env. Aggregate selectors iterate the remaining entries directly. - __root.tsx: track the last-seen primary env id in a ref and reconcile on every welcome / config event. On identity change: dispose the prior EnvironmentConnection, clear its store slice, and navigate the user off any /<oldEnvId>/<threadId> route they were sitting on. Bind startServerStateSync to the live primary id so it follows the swap. - auth: add reauthenticatePrimaryEnvironment() that re-runs the desktop bootstrap exchange against the new backend so the renderer holds a cookie signed by the live key. Without this, every subsequent HTTP request and the WS upgrade itself 401, since /ws on desktop primary authenticates via session cookie (no wsToken query param). Bump BOOTSTRAP_RETRY_TIMEOUT_MS from 15s to 60s to cover cold WSL launches. - runtime/{connection,service}: thread a reportLifecycleEvents option through dispose() so suppressed swaps do not churn the connection-status UI; add disconnectPrimaryEnvironment for the renderer to call when it reconciles env identity. - rpc/{wsConnectionState,protocol,wsTransport,wsRpcClient}: introduce suppressWsConnectionLifecycle so the settings handler can stop reporting connect/disconnect events to the connection-status atom during the deliberate swap window. The lifecycle gate consults a shouldReportLifecycleEvent hook composed with the existing handlers. - ProjectFavicon: render the placeholder folder icon when the env's HTTP base URL is briefly unavailable (e.g. during a swap) instead of building an invalid src URL.
1 parent 85569b1 commit 6289f48

15 files changed

Lines changed: 249 additions & 31 deletions

File tree

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { EnvironmentId } from "@t3tools/contracts";
22
import { FolderIcon } from "lucide-react";
33
import { useState } from "react";
4-
import { resolveEnvironmentHttpUrl } from "../environments/runtime";
4+
import { getEnvironmentHttpBaseUrl } from "../environments/runtime";
55

66
const loadedProjectFaviconSrcs = new Set<string>();
77

@@ -10,32 +10,38 @@ export function ProjectFavicon(input: {
1010
cwd: string;
1111
className?: string;
1212
}) {
13-
const src = resolveEnvironmentHttpUrl({
14-
environmentId: input.environmentId,
15-
pathname: "/api/project-favicon",
16-
searchParams: { cwd: input.cwd },
17-
});
13+
const httpBaseUrl = getEnvironmentHttpBaseUrl(input.environmentId);
14+
const src = httpBaseUrl
15+
? (() => {
16+
const url = new URL(httpBaseUrl);
17+
url.pathname = "/api/project-favicon";
18+
url.search = new URLSearchParams({ cwd: input.cwd }).toString();
19+
return url.toString();
20+
})()
21+
: null;
1822
const [status, setStatus] = useState<"loading" | "loaded" | "error">(() =>
19-
loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading",
23+
src && loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading",
2024
);
2125

2226
return (
2327
<>
24-
{status !== "loaded" ? (
28+
{status !== "loaded" || !src ? (
2529
<FolderIcon
2630
className={`size-3.5 shrink-0 text-muted-foreground/50 ${input.className ?? ""}`}
2731
/>
2832
) : null}
29-
<img
30-
src={src}
31-
alt=""
32-
className={`size-3.5 shrink-0 rounded-sm object-contain ${status === "loaded" ? "" : "hidden"} ${input.className ?? ""}`}
33-
onLoad={() => {
34-
loadedProjectFaviconSrcs.add(src);
35-
setStatus("loaded");
36-
}}
37-
onError={() => setStatus("error")}
38-
/>
33+
{src ? (
34+
<img
35+
src={src}
36+
alt=""
37+
className={`size-3.5 shrink-0 rounded-sm object-contain ${status === "loaded" ? "" : "hidden"} ${input.className ?? ""}`}
38+
onLoad={() => {
39+
loadedProjectFaviconSrcs.add(src);
40+
setStatus("loaded");
41+
}}
42+
onError={() => setStatus("error")}
43+
/>
44+
) : null}
3945
</>
4046
);
4147
}

apps/web/src/environments/primary/auth.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ async function waitForAuthenticatedSessionAfterBootstrap(): Promise<AuthSessionS
152152
}
153153

154154
const TRANSIENT_BOOTSTRAP_STATUS_CODES = new Set([502, 503, 504]);
155-
const BOOTSTRAP_RETRY_TIMEOUT_MS = 15_000;
155+
const BOOTSTRAP_RETRY_TIMEOUT_MS = 60_000;
156156
const BOOTSTRAP_RETRY_STEP_MS = 500;
157157

158158
export async function retryTransientBootstrap<T>(operation: () => Promise<T>): Promise<T> {
@@ -231,6 +231,24 @@ export async function submitServerAuthCredential(credential: string): Promise<vo
231231
stripPairingTokenFromUrl();
232232
}
233233

234+
/**
235+
* Re-runs the desktop bootstrap exchange so the renderer holds a session cookie
236+
* signed by the *current* backend's key. Required after a backend swap (e.g.
237+
* Windows ↔ WSL), since each backend uses a distinct session-signing key, so
238+
* cookies issued by the previous backend are rejected with 401 by the new one.
239+
*/
240+
export async function reauthenticatePrimaryEnvironment(): Promise<void> {
241+
const credential = getDesktopBootstrapCredential();
242+
if (!credential) {
243+
throw new Error("Desktop bootstrap credential is unavailable.");
244+
}
245+
246+
resolvedAuthenticatedGateState = null;
247+
bootstrapPromise = null;
248+
await exchangeBootstrapCredential(credential);
249+
await waitForAuthenticatedSessionAfterBootstrap();
250+
}
251+
234252
export async function createServerPairingCredential(
235253
label?: string,
236254
): Promise<AuthPairingCredentialResult> {

apps/web/src/environments/primary/context.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,13 @@ export function resolveInitialPrimaryEnvironmentDescriptor(): Promise<ExecutionE
109109
});
110110
}
111111

112+
export async function refreshPrimaryEnvironmentDescriptor(): Promise<ExecutionEnvironmentDescriptor> {
113+
primaryEnvironmentDescriptorPromise = null;
114+
const descriptor = await fetchPrimaryEnvironmentDescriptor();
115+
writePrimaryEnvironmentDescriptor(descriptor);
116+
return descriptor;
117+
}
118+
112119
export function __resetPrimaryEnvironmentBootstrapForTests(): void {
113120
primaryEnvironmentDescriptorPromise = null;
114121
usePrimaryEnvironmentBootstrapStore.getState().reset();

apps/web/src/environments/primary/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export {
22
getPrimaryKnownEnvironment,
33
readPrimaryEnvironmentDescriptor,
4+
refreshPrimaryEnvironmentDescriptor,
45
resetPrimaryEnvironmentDescriptorForTests,
56
resolveInitialPrimaryEnvironmentDescriptor,
67
usePrimaryEnvironmentId,
@@ -20,6 +21,7 @@ export {
2021
listServerClientSessions,
2122
listServerPairingLinks,
2223
peekPairingTokenFromUrl,
24+
reauthenticatePrimaryEnvironment,
2325
resolveInitialServerAuthGateState,
2426
revokeOtherServerClientSessions,
2527
revokeServerClientSession,

apps/web/src/environments/runtime/connection.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export interface EnvironmentConnection {
1717
readonly client: WsRpcClient;
1818
readonly ensureBootstrapped: () => Promise<void>;
1919
readonly reconnect: () => Promise<void>;
20-
readonly dispose: () => Promise<void>;
20+
readonly dispose: (options?: { readonly reportLifecycleEvents?: boolean }) => Promise<void>;
2121
}
2222

2323
interface OrchestrationHandlers {
@@ -165,9 +165,9 @@ export function createEnvironmentConnection(
165165
throw error;
166166
}
167167
},
168-
dispose: async () => {
168+
dispose: async (options) => {
169169
cleanup();
170-
await input.client.dispose();
170+
await input.client.dispose(options);
171171
},
172172
};
173173
}

apps/web/src/environments/runtime/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export {
1717
export {
1818
addSavedEnvironment,
1919
disconnectSavedEnvironment,
20+
disconnectPrimaryEnvironment,
2021
ensureEnvironmentConnectionBootstrapped,
2122
getPrimaryEnvironmentConnection,
2223
readEnvironmentConnection,

apps/web/src/environments/runtime/service.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -870,7 +870,10 @@ function registerConnection(connection: EnvironmentConnection): EnvironmentConne
870870
return connection;
871871
}
872872

873-
async function removeConnection(environmentId: EnvironmentId): Promise<boolean> {
873+
async function removeConnection(
874+
environmentId: EnvironmentId,
875+
options?: { readonly reportLifecycleEvents?: boolean },
876+
): Promise<boolean> {
874877
const connection = environmentConnections.get(environmentId);
875878
if (!connection) {
876879
return false;
@@ -880,7 +883,7 @@ async function removeConnection(environmentId: EnvironmentId): Promise<boolean>
880883
lastAppliedProjectionVersionByEnvironment.delete(environmentId);
881884
environmentConnections.delete(environmentId);
882885
emitEnvironmentConnectionRegistryChange();
883-
await connection.dispose();
886+
await connection.dispose(options);
884887
return true;
885888
}
886889

@@ -1045,6 +1048,15 @@ export async function disconnectSavedEnvironment(environmentId: EnvironmentId):
10451048
await removeConnection(environmentId).catch(() => false);
10461049
}
10471050

1051+
export async function disconnectPrimaryEnvironment(environmentId: EnvironmentId): Promise<void> {
1052+
const connection = environmentConnections.get(environmentId);
1053+
if (connection?.kind !== "primary") {
1054+
return;
1055+
}
1056+
1057+
await removeConnection(environmentId, { reportLifecycleEvents: false }).catch(() => false);
1058+
}
1059+
10481060
export async function reconnectSavedEnvironment(environmentId: EnvironmentId): Promise<void> {
10491061
const record = getSavedEnvironmentRecord(environmentId);
10501062
if (!record) {

apps/web/src/routes/__root.tsx

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { type ServerLifecycleWelcomePayload } from "@t3tools/contracts";
1+
import { type EnvironmentId, type ServerLifecycleWelcomePayload } from "@t3tools/contracts";
22
import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime";
33
import {
44
Outlet,
@@ -44,15 +44,18 @@ import { useStore } from "../store";
4444
import { useUiStateStore } from "../uiStateStore";
4545
import { syncBrowserChromeTheme } from "../hooks/useTheme";
4646
import {
47+
disconnectPrimaryEnvironment,
4748
ensureEnvironmentConnectionBootstrapped,
4849
getPrimaryEnvironmentConnection,
4950
startEnvironmentConnectionService,
5051
} from "../environments/runtime";
5152
import { configureClientTracing } from "../observability/clientTracing";
5253
import {
5354
ensurePrimaryEnvironmentReady,
55+
readPrimaryEnvironmentDescriptor,
5456
resolveInitialServerAuthGateState,
5557
updatePrimaryEnvironmentDescriptor,
58+
usePrimaryEnvironmentId,
5659
} from "../environments/primary";
5760

5861
export const Route = createRootRouteWithContext<{
@@ -187,7 +190,12 @@ function errorDetails(error: unknown): string {
187190
}
188191

189192
function ServerStateBootstrap() {
190-
useEffect(() => startServerStateSync(getPrimaryEnvironmentConnection().client.server), []);
193+
const primaryEnvironmentId = usePrimaryEnvironmentId();
194+
195+
useEffect(
196+
() => startServerStateSync(getPrimaryEnvironmentConnection().client.server),
197+
[primaryEnvironmentId],
198+
);
191199

192200
return null;
193201
}
@@ -222,11 +230,31 @@ function EventRouter() {
222230
const handledBootstrapThreadIdRef = useRef<string | null>(null);
223231
const seenServerConfigUpdateIdRef = useRef(getServerConfigUpdatedNotification()?.id ?? 0);
224232
const disposedRef = useRef(false);
233+
const primaryEnvironmentIdRef = useRef<EnvironmentId | null>(
234+
readPrimaryEnvironmentDescriptor()?.environmentId ?? null,
235+
);
225236
const serverConfig = useServerConfig();
226237

238+
const reconcilePrimaryEnvironment = useEffectEvent((nextEnvironmentId: EnvironmentId) => {
239+
const previousEnvironmentId = primaryEnvironmentIdRef.current;
240+
primaryEnvironmentIdRef.current = nextEnvironmentId;
241+
if (!previousEnvironmentId || previousEnvironmentId === nextEnvironmentId) {
242+
return;
243+
}
244+
245+
void disconnectPrimaryEnvironment(previousEnvironmentId);
246+
useStore.getState().clearPrimaryEnvironmentState(previousEnvironmentId);
247+
248+
const routeEnvironmentId = readPathname().split("/").filter(Boolean)[0] ?? null;
249+
if (routeEnvironmentId === previousEnvironmentId) {
250+
void navigate({ to: "/", replace: true });
251+
}
252+
});
253+
227254
const handleWelcome = useEffectEvent((payload: ServerLifecycleWelcomePayload | null) => {
228255
if (!payload) return;
229256

257+
reconcilePrimaryEnvironment(payload.environment.environmentId);
230258
updatePrimaryEnvironmentDescriptor(payload.environment);
231259
setActiveEnvironmentId(payload.environment.environmentId);
232260
void (async () => {
@@ -339,9 +367,10 @@ function EventRouter() {
339367
return;
340368
}
341369

370+
reconcilePrimaryEnvironment(serverConfig.environment.environmentId);
342371
updatePrimaryEnvironmentDescriptor(serverConfig.environment);
343372
setActiveEnvironmentId(serverConfig.environment.environmentId);
344-
}, [serverConfig, setActiveEnvironmentId]);
373+
}, [reconcilePrimaryEnvironment, serverConfig, setActiveEnvironmentId]);
345374

346375
useEffect(() => {
347376
disposedRef.current = false;

apps/web/src/rpc/protocol.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
recordWsConnectionClosed,
1515
recordWsConnectionErrored,
1616
recordWsConnectionOpened,
17+
isWsConnectionLifecycleSuppressed,
1718
WS_RECONNECT_MAX_RETRIES,
1819
} from "./wsConnectionState";
1920

@@ -22,6 +23,7 @@ export interface WsProtocolLifecycleHandlers {
2223
readonly onOpen?: () => void;
2324
readonly onError?: (message: string) => void;
2425
readonly onClose?: (details: { readonly code: number; readonly reason: string }) => void;
26+
readonly shouldReportLifecycleEvent?: () => boolean;
2527
}
2628

2729
export const makeWsRpcProtocolClient = RpcClient.make(WsRpcGroup);
@@ -59,31 +61,47 @@ function defaultLifecycleHandlers(): Required<WsProtocolLifecycleHandlers> {
5961
clearAllTrackedRpcRequests();
6062
recordWsConnectionClosed(details);
6163
},
64+
shouldReportLifecycleEvent: () => !isWsConnectionLifecycleSuppressed(),
6265
};
6366
}
6467

6568
function composeLifecycleHandlers(
6669
handlers?: WsProtocolLifecycleHandlers,
6770
): Required<WsProtocolLifecycleHandlers> {
6871
const defaults = defaultLifecycleHandlers();
72+
const shouldReportLifecycleEvent = () =>
73+
defaults.shouldReportLifecycleEvent() && handlers?.shouldReportLifecycleEvent?.() !== false;
6974

7075
return {
7176
onAttempt: (socketUrl) => {
77+
if (!shouldReportLifecycleEvent()) {
78+
return;
79+
}
7280
defaults.onAttempt(socketUrl);
7381
handlers?.onAttempt?.(socketUrl);
7482
},
7583
onOpen: () => {
84+
if (!shouldReportLifecycleEvent()) {
85+
return;
86+
}
7687
defaults.onOpen();
7788
handlers?.onOpen?.();
7889
},
7990
onError: (message) => {
91+
if (!shouldReportLifecycleEvent()) {
92+
return;
93+
}
8094
defaults.onError(message);
8195
handlers?.onError?.(message);
8296
},
8397
onClose: (details) => {
98+
if (!shouldReportLifecycleEvent()) {
99+
return;
100+
}
84101
defaults.onClose(details);
85102
handlers?.onClose?.(details);
86103
},
104+
shouldReportLifecycleEvent,
87105
};
88106
}
89107

apps/web/src/rpc/wsConnectionState.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ const INITIAL_WS_CONNECTION_STATUS = Object.freeze<WsConnectionStatus>({
4848
socketUrl: null,
4949
});
5050

51+
let wsConnectionLifecycleSuppressionDepth = 0;
52+
5153
export const wsConnectionStatusAtom = Atom.make(INITIAL_WS_CONNECTION_STATUS).pipe(
5254
Atom.keepAlive,
5355
Atom.withLabel("ws-connection-status"),
@@ -153,6 +155,25 @@ export function resetWsConnectionStateForTests(): void {
153155
appAtomRegistry.set(wsConnectionStatusAtom, INITIAL_WS_CONNECTION_STATUS);
154156
}
155157

158+
export function resetWsConnectionState(): void {
159+
appAtomRegistry.set(wsConnectionStatusAtom, INITIAL_WS_CONNECTION_STATUS);
160+
}
161+
162+
export function isWsConnectionLifecycleSuppressed(): boolean {
163+
return wsConnectionLifecycleSuppressionDepth > 0;
164+
}
165+
166+
export async function suppressWsConnectionLifecycle<T>(operation: () => Promise<T>): Promise<T> {
167+
wsConnectionLifecycleSuppressionDepth += 1;
168+
resetWsConnectionState();
169+
try {
170+
return await operation();
171+
} finally {
172+
wsConnectionLifecycleSuppressionDepth -= 1;
173+
resetWsConnectionState();
174+
}
175+
}
176+
156177
export function useWsConnectionStatus(): WsConnectionStatus {
157178
return useAtomValue(wsConnectionStatusAtom);
158179
}

0 commit comments

Comments
 (0)