feat(terminal): switch terminal transport to pure websocket with fallback (#762)
* feat(terminal): add resumable websocket transport Unify terminal input and stream traffic on `/api/terminal/ws` with a v2 control-frame protocol and advertised transport capabilities. Buffer recent PTY output on the server so rebinding clients can replay missed chunks after reconnects or startup races, while keeping SSE as a fallback stream path. Update the web terminal client and store to negotiate the new transport, track tab lifecycle, and avoid reopening exited sessions when restoring tabs. * fix(terminal): retry rehydrated websocket reconnects * fix(terminal): keep reconnect retries silent * fix(terminal): wire ws stream output and replay in runtime * fix(web): enable ws proxy for /api in dev --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
d68bec491c
commit
2b70ec6f3a
@@ -54,7 +54,7 @@ const STREAM_OPTIONS = {
|
||||
|
||||
const REHYDRATED_STREAM_OPTIONS = {
|
||||
retry: {
|
||||
maxRetries: 0,
|
||||
...STREAM_OPTIONS.retry,
|
||||
initialDelayMs: 200,
|
||||
maxDelayMs: 500,
|
||||
},
|
||||
@@ -106,6 +106,7 @@ export const TerminalView: React.FC = () => {
|
||||
const setActiveTab = terminalStore.setActiveTab;
|
||||
const closeTab = terminalStore.closeTab;
|
||||
const setTabSessionId = terminalStore.setTabSessionId;
|
||||
const setTabLifecycle = terminalStore.setTabLifecycle;
|
||||
const setConnecting = terminalStore.setConnecting;
|
||||
const appendToBuffer = terminalStore.appendToBuffer;
|
||||
|
||||
@@ -132,11 +133,13 @@ export const TerminalView: React.FC = () => {
|
||||
}, [directoryTerminalState, activeTabId]);
|
||||
|
||||
const terminalSessionId = activeTab?.terminalSessionId ?? null;
|
||||
const terminalLifecycle = activeTab?.lifecycle ?? 'idle';
|
||||
const bufferChunks = activeTab?.bufferChunks ?? [];
|
||||
const isConnecting = activeTab?.isConnecting ?? false;
|
||||
|
||||
const [connectionError, setConnectionError] = React.useState<string | null>(null);
|
||||
const [isFatalError, setIsFatalError] = React.useState(false);
|
||||
const [isReconnectPending, setIsReconnectPending] = React.useState(false);
|
||||
const [activeModifier, setActiveModifier] = React.useState<Modifier | null>(null);
|
||||
const [isRestarting, setIsRestarting] = React.useState(false);
|
||||
const [viewportLayoutVersion, setViewportLayoutVersion] = React.useState(0);
|
||||
@@ -234,6 +237,7 @@ export const TerminalView: React.FC = () => {
|
||||
streamCleanupRef.current?.();
|
||||
streamCleanupRef.current = null;
|
||||
activeTerminalIdRef.current = null;
|
||||
setIsReconnectPending(false);
|
||||
}, []);
|
||||
|
||||
React.useEffect(
|
||||
@@ -273,6 +277,7 @@ export const TerminalView: React.FC = () => {
|
||||
setConnecting(directory, tabId, false);
|
||||
setConnectionError(null);
|
||||
setIsFatalError(false);
|
||||
setIsReconnectPending(false);
|
||||
focusTerminalWhenWindowActive();
|
||||
|
||||
// After a reload, buffer is empty and a reused PTY can look "stuck"
|
||||
@@ -286,10 +291,10 @@ export const TerminalView: React.FC = () => {
|
||||
break;
|
||||
}
|
||||
case 'reconnecting': {
|
||||
const attempt = event.attempt ?? 0;
|
||||
const maxAttempts = event.maxAttempts ?? 3;
|
||||
setConnectionError(`Reconnecting (${attempt}/${maxAttempts})...`);
|
||||
void event;
|
||||
setConnectionError(null);
|
||||
setIsFatalError(false);
|
||||
setIsReconnectPending(true);
|
||||
break;
|
||||
}
|
||||
case 'data': {
|
||||
@@ -313,10 +318,12 @@ export const TerminalView: React.FC = () => {
|
||||
exitCode !== null ? ` with code ${exitCode}` : ''
|
||||
}${signal !== null ? ` (signal ${signal})` : ''}]\r\n`
|
||||
);
|
||||
setTabLifecycle(directory, tabId, 'exited');
|
||||
setTabSessionId(directory, tabId, null);
|
||||
setConnecting(directory, tabId, false);
|
||||
setConnectionError(isActionTab ? null : 'Terminal session ended');
|
||||
setIsFatalError(false);
|
||||
setIsReconnectPending(false);
|
||||
disconnectStream();
|
||||
break;
|
||||
}
|
||||
@@ -327,18 +334,19 @@ export const TerminalView: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const errorMsg = fatal
|
||||
? `Connection failed: ${error.message}`
|
||||
: error.message || 'Terminal stream connection error';
|
||||
|
||||
setConnectionError(errorMsg);
|
||||
setIsFatalError(!!fatal);
|
||||
|
||||
if (fatal) {
|
||||
setConnecting(directory, tabId, false);
|
||||
setTabSessionId(directory, tabId, null);
|
||||
disconnectStream();
|
||||
if (!fatal) {
|
||||
setConnectionError(null);
|
||||
setIsFatalError(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsReconnectPending(false);
|
||||
setConnectionError(`Connection failed: ${error.message}`);
|
||||
setIsFatalError(true);
|
||||
setConnecting(directory, tabId, false);
|
||||
setTabLifecycle(directory, tabId, 'exited');
|
||||
setTabSessionId(directory, tabId, null);
|
||||
disconnectStream();
|
||||
},
|
||||
},
|
||||
streamOptions
|
||||
@@ -349,7 +357,7 @@ export const TerminalView: React.FC = () => {
|
||||
activeTerminalIdRef.current = null;
|
||||
};
|
||||
},
|
||||
[appendToBuffer, disconnectStream, focusTerminalWhenWindowActive, setConnecting, setTabSessionId, terminal]
|
||||
[appendToBuffer, disconnectStream, focusTerminalWhenWindowActive, setConnecting, setTabLifecycle, setTabSessionId, terminal]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -389,6 +397,7 @@ export const TerminalView: React.FC = () => {
|
||||
|
||||
const tab = state.tabs.find((t) => t.id === tabId) ?? state.tabs[0];
|
||||
let terminalId = tab?.terminalSessionId ?? null;
|
||||
const terminalLifecycle = tab?.lifecycle ?? 'idle';
|
||||
const isActionTab = Boolean(tab?.label?.startsWith('Action:'));
|
||||
const hasBufferedOutput = (tab?.bufferLength ?? 0) > 0 || (tab?.bufferChunks?.length ?? 0) > 0;
|
||||
|
||||
@@ -402,6 +411,11 @@ export const TerminalView: React.FC = () => {
|
||||
Boolean(terminalId) && rehydratedTerminalIdsRef.current.has(terminalId as string);
|
||||
|
||||
if (!terminalId) {
|
||||
if (terminalLifecycle === 'exited') {
|
||||
setConnecting(directory, tabId, false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isActionTab && hasBufferedOutput) {
|
||||
setConnecting(directory, tabId, false);
|
||||
return;
|
||||
@@ -409,6 +423,7 @@ export const TerminalView: React.FC = () => {
|
||||
|
||||
setConnectionError(null);
|
||||
setIsFatalError(false);
|
||||
setIsReconnectPending(false);
|
||||
setConnecting(directory, tabId, true);
|
||||
try {
|
||||
const size = lastViewportSizeRef.current;
|
||||
@@ -440,6 +455,7 @@ export const TerminalView: React.FC = () => {
|
||||
: 'Failed to start terminal session'
|
||||
);
|
||||
setIsFatalError(true);
|
||||
setIsReconnectPending(false);
|
||||
setConnecting(directory, tabId, false);
|
||||
}
|
||||
return;
|
||||
@@ -476,12 +492,14 @@ export const TerminalView: React.FC = () => {
|
||||
hasActiveContext,
|
||||
effectiveDirectory,
|
||||
terminalSessionId,
|
||||
terminalLifecycle,
|
||||
activeTabId,
|
||||
hasOpenedTerminalViewport,
|
||||
enableTabs,
|
||||
terminalHydrated,
|
||||
ensureDirectory,
|
||||
setConnecting,
|
||||
setTabLifecycle,
|
||||
setTabSessionId,
|
||||
startStream,
|
||||
disconnectStream,
|
||||
@@ -520,6 +538,7 @@ export const TerminalView: React.FC = () => {
|
||||
setIsRestarting(true);
|
||||
setConnectionError(null);
|
||||
setIsFatalError(false);
|
||||
setIsReconnectPending(false);
|
||||
|
||||
disconnectStream();
|
||||
|
||||
@@ -528,6 +547,7 @@ export const TerminalView: React.FC = () => {
|
||||
} catch (error) {
|
||||
setConnectionError(error instanceof Error ? error.message : 'Failed to restart terminal');
|
||||
setIsFatalError(true);
|
||||
setIsReconnectPending(false);
|
||||
} finally {
|
||||
setIsRestarting(false);
|
||||
}
|
||||
@@ -544,6 +564,7 @@ export const TerminalView: React.FC = () => {
|
||||
setActiveTab(effectiveDirectory, tabId);
|
||||
setConnectionError(null);
|
||||
setIsFatalError(false);
|
||||
setIsReconnectPending(false);
|
||||
disconnectStream();
|
||||
}, [createTab, disconnectStream, effectiveDirectory, setActiveTab]);
|
||||
|
||||
@@ -553,6 +574,7 @@ export const TerminalView: React.FC = () => {
|
||||
setActiveTab(effectiveDirectory, tabId);
|
||||
setConnectionError(null);
|
||||
setIsFatalError(false);
|
||||
setIsReconnectPending(false);
|
||||
disconnectStream();
|
||||
},
|
||||
[disconnectStream, effectiveDirectory, setActiveTab]
|
||||
@@ -568,6 +590,7 @@ export const TerminalView: React.FC = () => {
|
||||
|
||||
setConnectionError(null);
|
||||
setIsFatalError(false);
|
||||
setIsReconnectPending(false);
|
||||
void closeTab(effectiveDirectory, tabId);
|
||||
},
|
||||
[activeTabId, closeTab, disconnectStream, effectiveDirectory]
|
||||
@@ -576,7 +599,7 @@ export const TerminalView: React.FC = () => {
|
||||
|
||||
const handleViewportInput = React.useCallback(
|
||||
(data: string) => {
|
||||
if (!data) {
|
||||
if (!data || isReconnectPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -602,7 +625,9 @@ export const TerminalView: React.FC = () => {
|
||||
if (!terminalId) return;
|
||||
|
||||
void terminal.sendInput(terminalId, payload).catch((error) => {
|
||||
setConnectionError(error instanceof Error ? error.message : 'Failed to send input');
|
||||
if (!isReconnectPending) {
|
||||
setConnectionError(error instanceof Error ? error.message : 'Failed to send input');
|
||||
}
|
||||
});
|
||||
|
||||
if (modifierConsumed) {
|
||||
@@ -610,7 +635,7 @@ export const TerminalView: React.FC = () => {
|
||||
terminalControllerRef.current?.focus();
|
||||
}
|
||||
},
|
||||
[activeModifier, setActiveModifier, terminal]
|
||||
[activeModifier, isReconnectPending, setActiveModifier, terminal]
|
||||
);
|
||||
|
||||
const handleViewportResize = React.useCallback(
|
||||
@@ -855,7 +880,7 @@ export const TerminalView: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const quickKeysDisabled = !terminalSessionId || isConnecting || isRestarting;
|
||||
const quickKeysDisabled = !terminalSessionId || isConnecting || isRestarting || isReconnectPending;
|
||||
const shouldRenderViewport = isMobile ? isTerminalVisible : hasOpenedTerminalViewport;
|
||||
const quickKeysControls = (
|
||||
<>
|
||||
@@ -1075,7 +1100,7 @@ export const TerminalView: React.FC = () => {
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{connectionError && (
|
||||
{!isReconnectPending && connectionError && (
|
||||
<div className="absolute inset-x-0 bottom-0 bg-[var(--status-error-background)] px-3 py-2 text-xs text-[var(--status-error-foreground)] flex items-center justify-between gap-2">
|
||||
<span>{connectionError}</span>
|
||||
{isFatalError && isMobile && (
|
||||
|
||||
Reference in New Issue
Block a user