Skip to content

Commit 83dc94e

Browse files
committed
fix(session): restore terminal input after page refresh
- Fix upsertWorkspaceSnapshot preserving activeTabId when uiState is absent (background reattach was overwriting route workspace to first tab) - Fix auth_status_handler and rpc_handler blocking Tokio async runtime by wrapping synchronous DB reads in spawn_blocking - Fix terminal_channel_input error events passing real workspace_id instead of fixed "terminal-channel" string - Add workspace://input_error subscription with auto-recovery: detect stale runtime/binding errors, force reattach, restart if needed - Add runtimeLiveness check before sending input (require attached state) - Add regression test for activeTabId preservation across snapshots
1 parent 659475a commit 83dc94e

8 files changed

Lines changed: 290 additions & 59 deletions

File tree

apps/server/src/command/http.rs

Lines changed: 39 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1634,20 +1634,29 @@ pub(crate) async fn auth_status_handler(
16341634
ConnectInfo(client_addr): ConnectInfo<std::net::SocketAddr>,
16351635
AxumState(state): AxumState<HttpServerState>,
16361636
) -> Response {
1637-
match auth_status(
1638-
&state.app,
1639-
&headers,
1640-
client_addr,
1641-
request_forces_public_mode(&uri),
1642-
) {
1643-
Ok(data) => json_success(serde_json::to_value(data).unwrap_or(Value::Null)),
1644-
Err(error) => error.into_response(&RequestContext {
1645-
ip: client_addr.ip().to_string(),
1637+
let app = state.app.clone();
1638+
let headers = headers.clone();
1639+
let force_public = request_forces_public_mode(&uri);
1640+
let ip = client_addr.ip().to_string();
1641+
1642+
let blocking_result = tokio::task::spawn_blocking(move || {
1643+
auth_status(&app, &headers, client_addr, force_public)
1644+
})
1645+
.await;
1646+
1647+
match blocking_result {
1648+
Ok(Ok(data)) => json_success(serde_json::to_value(data).unwrap_or(Value::Null)),
1649+
Ok(Err(error)) => error.into_response(&RequestContext {
1650+
ip,
16461651
user_agent: String::new(),
16471652
is_local_host: client_addr.ip().is_loopback(),
16481653
is_secure_transport: false,
16491654
public_mode: true,
16501655
}),
1656+
Err(error) => json_error(
1657+
StatusCode::INTERNAL_SERVER_ERROR,
1658+
format!("auth_status_task_join_failed: {error}"),
1659+
),
16511660
}
16521661
}
16531662

@@ -1740,6 +1749,13 @@ pub(crate) async fn rpc_handler(
17401749
AxumState(state): AxumState<HttpServerState>,
17411750
Json(payload): Json<Value>,
17421751
) -> Response {
1752+
let request_context = RequestContext {
1753+
ip: client_addr.ip().to_string(),
1754+
user_agent: String::new(),
1755+
is_local_host: client_addr.ip().is_loopback(),
1756+
is_secure_transport: false,
1757+
public_mode: true,
1758+
};
17431759
let authorized = match require_session(
17441760
&state.app,
17451761
&headers,
@@ -1748,19 +1764,23 @@ pub(crate) async fn rpc_handler(
17481764
) {
17491765
Ok(authorized) => authorized,
17501766
Err(error) => {
1751-
return error.into_response(&RequestContext {
1752-
ip: client_addr.ip().to_string(),
1753-
user_agent: String::new(),
1754-
is_local_host: client_addr.ip().is_loopback(),
1755-
is_secure_transport: false,
1756-
public_mode: true,
1757-
})
1767+
return error.into_response(&request_context)
17581768
}
17591769
};
17601770

1761-
match dispatch_rpc(&state.app, &command, payload, &authorized) {
1762-
Ok(data) => json_success(data),
1763-
Err(error) => json_error(error.status, error.error),
1771+
let app = state.app.clone();
1772+
let blocking_result = tokio::task::spawn_blocking(move || {
1773+
dispatch_rpc(&app, &command, payload, &authorized)
1774+
})
1775+
.await;
1776+
1777+
match blocking_result {
1778+
Ok(Ok(data)) => json_success(data),
1779+
Ok(Err(error)) => json_error(error.status, error.error),
1780+
Err(error) => json_error(
1781+
StatusCode::INTERNAL_SERVER_ERROR,
1782+
format!("rpc_task_join_failed: {error}"),
1783+
),
17641784
}
17651785
}
17661786

apps/server/src/ws/server.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -470,7 +470,7 @@ fn handle_ws_client_envelope(
470470
}),
471471
)
472472
.map_err(|error| {
473-
ws_input_error_envelope("terminal-channel", "terminal_channel_input", &error)
473+
ws_input_error_envelope(&workspace_id, "terminal_channel_input", &error)
474474
})?;
475475
Ok(None)
476476
}

apps/web/src/command/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@ export { subscribeTerminalEvents } from "./terminal.command";
33
export {
44
subscribeWorkspaceArtifactsDirty,
55
subscribeWorkspaceController,
6+
subscribeWorkspaceInputError,
67
subscribeWorkspaceRuntimeState,
78
} from "./workspace.command";

apps/web/src/command/workspace.command.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type {
22
ArtifactsDirtyEvent,
3+
WorkspaceInputErrorEvent,
34
WorkspaceRuntimeControllerEvent,
45
WorkspaceRuntimeStateEvent,
56
} from "../types/app";
@@ -13,3 +14,6 @@ export const subscribeWorkspaceController = (handler: (payload: WorkspaceRuntime
1314

1415
export const subscribeWorkspaceRuntimeState = (handler: (payload: WorkspaceRuntimeStateEvent) => void) =>
1516
subscribeWsEvent<WorkspaceRuntimeStateEvent>("workspace://runtime_state", handler);
17+
18+
export const subscribeWorkspaceInputError = (handler: (payload: WorkspaceInputErrorEvent) => void) =>
19+
subscribeWsEvent<WorkspaceInputErrorEvent>("workspace://input_error", handler);

apps/web/src/features/workspace/WorkspaceScreen.tsx

Lines changed: 145 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,9 @@ import {
103103
} from "./workspace-view-persistence";
104104
import { createWorkspaceSessionActions } from "./session-actions";
105105
import { useWorkspaceArtifactsSync } from "./workspace-sync-hooks";
106+
import {
107+
subscribeWorkspaceInputError,
108+
} from "../../command";
106109
import { startWorkspaceLaunch } from "./workspace-launch-actions";
107110
import {
108111
browseWorkspaceOverlayDirectory,
@@ -129,9 +132,9 @@ import {
129132
withWorkspaceFileSearchDropdownStyle
130133
} from "./file-search-actions";
131134
import { startSessionRuntime } from "../../services/http/session-runtime.service.ts";
135+
import { writeTerminal as writeTerminalRequest } from "../../services/http/terminal.service";
132136
import {
133137
consumeTerminalChannelInputFragment,
134-
sendTerminalChannelInput,
135138
} from "../../services/terminal-channel/client.ts";
136139
import { withFallback } from "../../services/http/client";
137140
import {
@@ -224,6 +227,12 @@ import type {
224227

225228
const withServiceFallback = async <T,>(operation: () => Promise<T>, fallback: T): Promise<T> => withFallback(operation, fallback);
226229

230+
const STALE_SESSION_INPUT_ERRORS = new Set([
231+
"terminal_runtime_not_found",
232+
"session_runtime_unbound",
233+
"terminal_stdin_closed",
234+
]);
235+
227236
type WorkspaceScreenProps = {
228237
locale: Locale;
229238
appSettings: AppSettings;
@@ -700,30 +709,40 @@ export default function WorkspaceScreen({ locale, appSettings, onOpenSettings }:
700709
}
701710

702711
if (uiState && runtimeSnapshot) {
703-
updateState((current) => applyWorkspaceBootstrapResult(
704-
current,
705-
bootstrap,
706-
locale,
707-
appSettings,
708-
{
709-
deviceId,
710-
clientId,
711-
uiState,
712-
runtimeSnapshot,
713-
},
714-
));
712+
updateState((current) => {
713+
const next = applyWorkspaceBootstrapResult(
714+
current,
715+
bootstrap,
716+
locale,
717+
appSettings,
718+
{
719+
deviceId,
720+
clientId,
721+
uiState,
722+
runtimeSnapshot,
723+
},
724+
);
725+
return next.tabs.some((tab) => tab.id === routeWorkspaceId)
726+
? { ...next, activeTabId: routeWorkspaceId }
727+
: next;
728+
});
715729
} else {
716-
updateState((current) => applyWorkspaceBootstrapResult(
717-
current,
718-
bootstrap,
719-
locale,
720-
appSettings,
721-
{
722-
deviceId,
723-
clientId,
724-
uiState,
725-
},
726-
));
730+
updateState((current) => {
731+
const next = applyWorkspaceBootstrapResult(
732+
current,
733+
bootstrap,
734+
locale,
735+
appSettings,
736+
{
737+
deviceId,
738+
clientId,
739+
uiState,
740+
},
741+
);
742+
return next.tabs.some((tab) => tab.id === routeWorkspaceId)
743+
? { ...next, activeTabId: routeWorkspaceId }
744+
: next;
745+
});
727746
}
728747
} else {
729748
updateState((current) => applyWorkspaceBootstrapResult(
@@ -753,7 +772,12 @@ export default function WorkspaceScreen({ locale, appSettings, onOpenSettings }:
753772
switchWorkspaceLocally(routeWorkspaceId);
754773
void withServiceFallback(() => activateWorkspaceRequest(routeWorkspaceId, deviceId, clientId), null).then((uiState) => {
755774
if (!uiState || cancelled || !isWorkspaceSyncVersionCurrent(routeWorkspaceId, syncVersion)) return;
756-
updateState((current) => applyWorkbenchUiState(current, uiState));
775+
updateState((current) => {
776+
const next = applyWorkbenchUiState(current, uiState);
777+
return next.tabs.some((tab) => tab.id === routeWorkspaceId)
778+
? { ...next, activeTabId: routeWorkspaceId }
779+
: next;
780+
});
757781
});
758782
}
759783
void ensureWorkspaceTerminal(routeWorkspaceId);
@@ -800,15 +824,20 @@ export default function WorkspaceScreen({ locale, appSettings, onOpenSettings }:
800824
navigate("/workspace", { replace: true });
801825
return;
802826
}
803-
updateState((current) => applyWorkspaceRuntimeSnapshot(
804-
current,
805-
runtimeSnapshot,
806-
locale,
807-
appSettings,
808-
deviceId,
809-
clientId,
810-
uiState,
811-
));
827+
updateState((current) => {
828+
const next = applyWorkspaceRuntimeSnapshot(
829+
current,
830+
runtimeSnapshot,
831+
locale,
832+
appSettings,
833+
deviceId,
834+
clientId,
835+
uiState,
836+
);
837+
return next.tabs.some((tab) => tab.id === routeWorkspaceId)
838+
? { ...next, activeTabId: routeWorkspaceId }
839+
: next;
840+
});
812841
void ensureWorkspaceTerminal(routeWorkspaceId);
813842
})();
814843
return () => {
@@ -1821,9 +1850,41 @@ export default function WorkspaceScreen({ locale, appSettings, onOpenSettings }:
18211850
return { tab: nextTab, session: nextSession };
18221851
};
18231852

1853+
const isSessionRuntimeWritable = (session: Session | null | undefined) => (
1854+
Boolean(session?.terminalRuntimeId) && session?.runtimeLiveness === "attached"
1855+
);
1856+
1857+
const refreshWorkspaceRuntimeSnapshot = useCallback(async (workspaceId: string) => {
1858+
const syncVersion = advanceWorkspaceSyncVersion(workspaceId);
1859+
const runtimeSnapshot = await attachWorkspaceRuntimeWithRetry(
1860+
workspaceId,
1861+
deviceId,
1862+
clientId,
1863+
withServiceFallback,
1864+
{
1865+
force: true,
1866+
successReuseMs: 0,
1867+
},
1868+
);
1869+
if (runtimeSnapshot && isWorkspaceSyncVersionCurrent(workspaceId, syncVersion)) {
1870+
updateState((current) => applyWorkspaceRuntimeSnapshot(
1871+
current,
1872+
runtimeSnapshot,
1873+
locale,
1874+
appSettings,
1875+
deviceId,
1876+
clientId,
1877+
));
1878+
}
1879+
if (!isWorkspaceSyncVersionCurrent(workspaceId, syncVersion)) return null;
1880+
return stateRef.current.tabs.find((tab) => tab.id === workspaceId) ?? null;
1881+
}, [appSettings, clientId, deviceId, locale, updateState]);
1882+
18241883
const sendAgentRawChunk = async (tab: Tab, session: Session, input: string) => {
18251884
if (!guardWorkspaceMutation("agent_input", tab.id, session.id)) return false;
1826-
if (!session.terminalRuntimeId) return false;
1885+
if (!session.terminalRuntimeId || session.runtimeLiveness !== "attached" || !session.terminalId) return false;
1886+
const numericTerminalId = Number(session.terminalId.replace("term-", ""));
1887+
if (!Number.isFinite(numericTerminalId)) return false;
18271888
const lastActiveAt = Date.now();
18281889
updateTab(tab.id, (current) => ({
18291890
...current,
@@ -1832,8 +1893,12 @@ export default function WorkspaceScreen({ locale, appSettings, onOpenSettings }:
18321893
)
18331894
}));
18341895
void syncSessionPatch(tab.id, session.id, { last_active_at: lastActiveAt });
1835-
sendTerminalChannelInput(tab.id, tab.controller.deviceId, tab.controller.clientId, tab.controller.fencingToken, session.terminalRuntimeId, input);
1836-
return true;
1896+
try {
1897+
await writeTerminalRequest(tab.id, tab.controller, numericTerminalId, input);
1898+
return true;
1899+
} catch {
1900+
return false;
1901+
}
18371902
};
18381903

18391904
const onRecoverActiveSession = async () => {
@@ -2373,13 +2438,28 @@ export default function WorkspaceScreen({ locale, appSettings, onOpenSettings }:
23732438
let sessionSnapshot = materialized?.session ?? activeSessionSnapshot;
23742439
if (!tabSnapshot || !sessionSnapshot) return null;
23752440

2376-
if (!sessionSnapshot.terminalRuntimeId) {
2441+
if (!isSessionRuntimeWritable(sessionSnapshot)) {
2442+
const needsRefresh = Boolean(sessionSnapshot.terminalRuntimeId)
2443+
|| sessionSnapshot.runtimeLiveness === "runtime_missing"
2444+
|| sessionSnapshot.runtimeLiveness === "provider_exited"
2445+
|| !sessionSnapshot.runtimeLiveness;
2446+
if (needsRefresh) {
2447+
const refreshedTab = await refreshWorkspaceRuntimeSnapshot(tabSnapshot.id);
2448+
if (refreshedTab) {
2449+
tabSnapshot = refreshedTab;
2450+
sessionSnapshot = refreshedTab.sessions.find((session) => session.id === sessionSnapshot.id) ?? sessionSnapshot;
2451+
}
2452+
}
2453+
}
2454+
2455+
if (!isSessionRuntimeWritable(sessionSnapshot)) {
23772456
const started = await startSessionRuntimeInPane(paneId, tabSnapshot, sessionSnapshot);
23782457
if (!started) return null;
23792458
tabSnapshot = started.tab;
23802459
sessionSnapshot = started.session;
23812460
}
23822461

2462+
if (!isSessionRuntimeWritable(sessionSnapshot)) return null;
23832463
touchSession(tabSnapshot.id, sessionSnapshot.id);
23842464
return { tab: tabSnapshot, session: sessionSnapshot };
23852465
};
@@ -2450,6 +2530,33 @@ export default function WorkspaceScreen({ locale, appSettings, onOpenSettings }:
24502530
focusWorkspaceAgentPane(paneId);
24512531
};
24522532

2533+
useEffect(() => {
2534+
const unsubscribe = subscribeWorkspaceInputError((payload) => {
2535+
if (!STALE_SESSION_INPUT_ERRORS.has(payload.error)) return;
2536+
const tab = stateRef.current.tabs.find((item) => item.id === payload.workspace_id);
2537+
if (!tab) return;
2538+
void (async () => {
2539+
let refreshedTab = await refreshWorkspaceRuntimeSnapshot(tab.id);
2540+
if (!refreshedTab) {
2541+
refreshedTab = stateRef.current.tabs.find((item) => item.id === tab.id) ?? null;
2542+
}
2543+
if (!refreshedTab || stateRef.current.activeTabId !== refreshedTab.id) return;
2544+
const paneSessionId = findPaneSessionId(refreshedTab.paneLayout, refreshedTab.activePaneId) ?? refreshedTab.activeSessionId;
2545+
const session = refreshedTab.sessions.find((item) => item.id === paneSessionId);
2546+
if (!session || session.unavailableReason || isSessionRuntimeWritable(session)) return;
2547+
const restarted = await startSessionRuntimeInPane(refreshedTab.activePaneId, refreshedTab, session);
2548+
if (!restarted) {
2549+
addToast({
2550+
id: createId("toast"),
2551+
text: `Session input recovery failed: ${payload.error}`,
2552+
sessionId: session.id,
2553+
});
2554+
}
2555+
})();
2556+
});
2557+
return () => unsubscribe();
2558+
}, [addToast, refreshWorkspaceRuntimeSnapshot]);
2559+
24532560
useEffect(() => {
24542561
return () => {
24552562
for (const timer of agentTerminalInputFlushTimerRef.current.values()) {

apps/web/src/shared/utils/workspace.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -563,10 +563,14 @@ export const upsertWorkspaceSnapshot = (
563563
}
564564
const tabs = orderTabsByUiState(Array.from(tabMap.values()), openWorkspaceIds);
565565

566+
const nextActiveWorkspaceId = uiState
567+
? uiState.active_workspace_id
568+
: (current.activeTabId || nextTab.id);
569+
566570
const nextState = {
567571
...current,
568572
tabs,
569-
activeTabId: resolveActiveWorkspaceId(tabs, uiState?.active_workspace_id ?? nextTab.id),
573+
activeTabId: resolveActiveWorkspaceId(tabs, nextActiveWorkspaceId),
570574
layout: uiState ? workbenchLayoutFromBackend(uiState.layout) : current.layout,
571575
overlay: {
572576
...current.overlay,

0 commit comments

Comments
 (0)