Skip to content

Commit 11324ca

Browse files
matt423claude
andcommitted
Refresh credentials before each handshake so reconnects don't expire
Signed configs are short-lived (the terminal server rejects configs older than a few minutes). A resume-on-return — or any auto-reconnect more than a few minutes into a session — reused the original signed config and was rejected with "Config expired", forcing a noisy retry. Add an optional async refreshCredentials() prop that the component awaits in the open handler, just before sending the auth payload, for both the primary and secondary (split-screen) terminals. Embedders return a fresh signed config; when absent or on error we fall back to the signedConfig/signature props, so behaviour is unchanged for consumers that don't supply it. DX-1379 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f25a9dd commit 11324ca

2 files changed

Lines changed: 97 additions & 41 deletions

File tree

1.97 KB
Binary file not shown.

packages/react-web-cli/src/AblyCliTerminal.tsx

Lines changed: 97 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,17 @@ export interface AblyCliTerminalProperties {
126126
* dev) to exercise the pause/resume cycle without waiting.
127127
*/
128128
inactivityTimeoutMs?: number;
129+
/**
130+
* Called right before each (re)connection handshake to obtain fresh auth.
131+
* Short-lived signed configs expire (the terminal server rejects configs
132+
* older than a few minutes), so a resume or reconnect after the tab has been
133+
* idle would otherwise fail with "Config expired". Return fresh credentials to
134+
* use for the handshake, or null/undefined to keep the current
135+
* signedConfig/signature props. Errors are swallowed and fall back to props.
136+
*/
137+
refreshCredentials?: () => Promise<
138+
{ signedConfig: string; signature: string } | null | undefined
139+
>;
129140
/**
130141
* When true, enables split-screen mode with a second independent terminal.
131142
* A split icon will be displayed in the top-right corner when in single-pane mode.
@@ -184,6 +195,7 @@ const AblyCliTerminalInner = (
184195
resumeOnReload,
185196
maxReconnectAttempts,
186197
inactivityTimeoutMs,
198+
refreshCredentials,
187199
enableSplitScreen = false,
188200
showSplitControl = true,
189201
}: AblyCliTerminalProperties,
@@ -560,6 +572,41 @@ const AblyCliTerminalInner = (
560572
// Set while an inactivity-resume attempt is in flight, so a failed resume can
561573
// fall back to a fresh session instead of dead-ending on the manual prompt.
562574
const resumeAttemptReference = useRef<boolean>(false);
575+
576+
// Effective auth used for handshakes. Starts from the props and is replaced by
577+
// refreshCredentials() output (when provided) so resumes/reconnects after the
578+
// tab has been idle use a fresh, non-expired signed config.
579+
const refreshCredentialsReference = useRef(refreshCredentials);
580+
const effectiveSignedConfigReference = useRef(signedConfig);
581+
const effectiveSignatureReference = useRef(signature);
582+
useEffect(() => {
583+
refreshCredentialsReference.current = refreshCredentials;
584+
}, [refreshCredentials]);
585+
useEffect(() => {
586+
effectiveSignedConfigReference.current = signedConfig;
587+
}, [signedConfig]);
588+
useEffect(() => {
589+
effectiveSignatureReference.current = signature;
590+
}, [signature]);
591+
592+
// Pull fresh credentials before a handshake; falls back to current props.
593+
const refreshAuth = useCallback(async () => {
594+
const fn = refreshCredentialsReference.current;
595+
if (!fn) return;
596+
try {
597+
const fresh = await fn();
598+
if (fresh?.signedConfig && fresh?.signature) {
599+
effectiveSignedConfigReference.current = fresh.signedConfig;
600+
effectiveSignatureReference.current = fresh.signature;
601+
}
602+
} catch (error) {
603+
debugLog(
604+
"[AblyCLITerminal] refreshCredentials failed; using existing auth",
605+
error,
606+
);
607+
}
608+
}, []);
609+
563610
// Guard to ensure we do NOT double-count a failed attempt when both the
564611
// `error` and the subsequent `close` events fire for the *same* socket.
565612
const reconnectScheduledThisCycleReference = useRef<boolean>(false);
@@ -1435,7 +1482,7 @@ const AblyCliTerminalInner = (
14351482

14361483
const socketReference = useRef<WebSocket | null>(null); // Ref to hold the current socket for cleanup
14371484

1438-
const handleWebSocketOpen = useCallback(() => {
1485+
const handleWebSocketOpen = useCallback(async () => {
14391486
// console.log('[AblyCLITerminal] WebSocket opened');
14401487
// Clear connection timeout since we successfully connected
14411488
clearConnectionTimeout();
@@ -1473,8 +1520,16 @@ const AblyCliTerminalInner = (
14731520
// Don't send the initial command yet - wait for prompt detection
14741521
}
14751522

1523+
// Refresh credentials before authenticating so a resume/reconnect after the
1524+
// tab has been idle doesn't hand the server an expired signed config.
1525+
await refreshAuth();
1526+
14761527
// Send auth payload with signed config
1477-
const payload = createAuthPayload(sessionId, signedConfig, signature);
1528+
const payload = createAuthPayload(
1529+
sessionId,
1530+
effectiveSignedConfigReference.current,
1531+
effectiveSignatureReference.current,
1532+
);
14781533

14791534
debugLog(
14801535
`⚠️ DIAGNOSTIC: Preparing to send auth payload with env vars: ${JSON.stringify(payload.environmentVariables)}`,
@@ -1513,8 +1568,7 @@ const AblyCliTerminalInner = (
15131568
clearPtyBuffer,
15141569
sessionId,
15151570
clearConnectionTimeout,
1516-
signedConfig,
1517-
signature,
1571+
refreshAuth,
15181572
]);
15191573

15201574
const handleWebSocketMessage = useCallback(
@@ -2520,7 +2574,10 @@ const AblyCliTerminalInner = (
25202574
const messageListener = (event: MessageEvent) => {
25212575
void handleWebSocketMessage(event);
25222576
};
2523-
socket.addEventListener("open", handleWebSocketOpen);
2577+
const openListener = () => {
2578+
void handleWebSocketOpen();
2579+
};
2580+
socket.addEventListener("open", openListener);
25242581
socket.addEventListener("message", messageListener);
25252582
socket.addEventListener("close", handleWebSocketClose);
25262583
socket.addEventListener("error", handleWebSocketError);
@@ -2530,7 +2587,7 @@ const AblyCliTerminalInner = (
25302587
debugLog(
25312588
"[AblyCLITerminal] Cleaning up WebSocket event listeners for old socket.",
25322589
);
2533-
socket.removeEventListener("open", handleWebSocketOpen);
2590+
socket.removeEventListener("open", openListener);
25342591
socket.removeEventListener("message", messageListener);
25352592
socket.removeEventListener("close", handleWebSocketClose);
25362593
socket.removeEventListener("error", handleWebSocketError);
@@ -3001,39 +3058,44 @@ const AblyCliTerminalInner = (
30013058

30023059
// WebSocket open handler
30033060
newSocket.addEventListener("open", () => {
3004-
debugLog("[AblyCLITerminal] Secondary WebSocket opened");
3005-
3006-
// Clear any reconnect prompt
3007-
setSecondaryShowManualReconnectPrompt(false);
3008-
secondaryShowManualReconnectPromptReference.current = false;
3061+
void (async () => {
3062+
debugLog("[AblyCLITerminal] Secondary WebSocket opened");
30093063

3010-
if (secondaryTerm.current) {
3011-
secondaryTerm.current.focus();
3012-
}
3064+
// Clear any reconnect prompt
3065+
setSecondaryShowManualReconnectPrompt(false);
3066+
secondaryShowManualReconnectPromptReference.current = false;
30133067

3014-
// Send auth payload with signed config
3015-
const payload = createAuthPayload(
3016-
secondarySessionId,
3017-
signedConfig,
3018-
signature,
3019-
);
3068+
if (secondaryTerm.current) {
3069+
secondaryTerm.current.focus();
3070+
}
30203071

3021-
if (newSocket.readyState === WebSocket.OPEN) {
3022-
newSocket.send(JSON.stringify(payload));
3023-
}
3072+
// Refresh credentials before authenticating (see primary handler).
3073+
await refreshAuth();
30243074

3025-
// Set up initial command to be sent when prompt is detected
3026-
// Skip initial command if we're resuming an existing session
3027-
if (initialCommand && !secondarySessionId) {
3028-
debugLog(
3029-
`[AblyCLITerminal] [Secondary] Initial command present: "${initialCommand}" - will be sent when prompt is detected`,
3075+
// Send auth payload with signed config
3076+
const payload = createAuthPayload(
3077+
secondarySessionId,
3078+
effectiveSignedConfigReference.current,
3079+
effectiveSignatureReference.current,
30303080
);
3031-
pendingSecondaryInitialCommandReference.current = initialCommand;
3032-
} else if (initialCommand && secondarySessionId) {
3033-
debugLog(
3034-
`[AblyCLITerminal] [Secondary] Skipping initial command for resumed session ${secondarySessionId}`,
3035-
);
3036-
}
3081+
3082+
if (newSocket.readyState === WebSocket.OPEN) {
3083+
newSocket.send(JSON.stringify(payload));
3084+
}
3085+
3086+
// Set up initial command to be sent when prompt is detected
3087+
// Skip initial command if we're resuming an existing session
3088+
if (initialCommand && !secondarySessionId) {
3089+
debugLog(
3090+
`[AblyCLITerminal] [Secondary] Initial command present: "${initialCommand}" - will be sent when prompt is detected`,
3091+
);
3092+
pendingSecondaryInitialCommandReference.current = initialCommand;
3093+
} else if (initialCommand && secondarySessionId) {
3094+
debugLog(
3095+
`[AblyCLITerminal] [Secondary] Skipping initial command for resumed session ${secondarySessionId}`,
3096+
);
3097+
}
3098+
})();
30373099
});
30383100

30393101
// WebSocket message handler with binary framing support
@@ -3322,13 +3384,7 @@ const AblyCliTerminalInner = (
33223384

33233385
return newSocket;
33243386
// eslint-disable-next-line react-hooks/exhaustive-deps -- callback is accessed via connectSecondaryWebSocketReference ref; missing deps include hoisted callbacks and state that would cause cascading recreations
3325-
}, [
3326-
websocketUrl,
3327-
signedConfig,
3328-
signature,
3329-
resumeOnReload,
3330-
secondarySessionId,
3331-
]);
3387+
}, [websocketUrl, refreshAuth, resumeOnReload, secondarySessionId]);
33323388

33333389
// Keep the ref updated with the latest callback
33343390
connectSecondaryWebSocketReference.current = connectSecondaryWebSocket;

0 commit comments

Comments
 (0)