fix(workspace): 旧版布局模板不再误杀 SSH 会话

legacy(M6.1 前单 dockview)模板只喂 terminal dockview,成功路径却无条件
rebind 两个 dockview 并 kill 全部 previousSessions,SSH 活面板被降级为本地
终端、会话被静默销毁。成功路径改为按 sshTouched 门控 rebind、按 live 集合
过滤收尾 kill(与错误路径同一语义)。

另:handleApplyTemplate 加在途守卫(并发应用会互杀对方刚恢复的会话);
主机密钥队列加 120s TTL 清扫(主进程超时后不再广播,陈旧弹窗会永远占住
只渲染队头的队列),respondHostKey 移出 setState updater。
This commit is contained in:
Bill committed 2026-10-09 11:30:34 +08:00
1 parent 06cfc61392
commit 0289dcbd1d
1 file changed
+163 -99
+163 -99
View File
@@ -99,6 +99,22 @@ const DOCKVIEW_TAB_COMPONENTS = {
) )
} }
/** A queued host-key prompt plus the time it was queued — see the TTL sweep. */
interface QueuedHostKeyPrompt extends HostKeyPromptEvent {
enqueuedAt: number
}
/**
* How long a queued host-key prompt is worth answering. Mirrors the main
* process' `DEFAULT_TIMEOUTS.prompt` (src/main/ssh.ts): once that elapses the
* prompt has already been resolved as a reject and dropped there, so a
* decision of ours can no longer reach it.
*/
const HOST_KEY_PROMPT_TTL_MS = 120_000
/** Expired-prompt sweep interval (rendering draws the queue head only). */
const HOST_KEY_SWEEP_MS = 5_000
/** /**
* Electron re-throws a main-process rejection as * Electron re-throws a main-process rejection as
* `Error: Error invoking remote method 'x': Error: <real message>`, so the real * `Error: Error invoking remote method 'x': Error: <real message>`, so the real
@@ -333,8 +349,12 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
/** Re-entrancy guard for `connect` — a second attempt while one is in flight /** Re-entrancy guard for `connect` — a second attempt while one is in flight
* is dropped (openSession cannot be aborted and would orphan a session). */ * is dropped (openSession cannot be aborted and would orphan a session). */
const connectInFlightRef = useRef(0) const connectInFlightRef = useRef(0)
/** Re-entrancy guard for `handleApplyTemplate` — an overlapping second apply
* would collect the sessions the first one just restored as its own
* "previous" set and kill them. */
const applyTemplateInFlightRef = useRef(false)
/** FIFO of pending host-key confirmations — render the head only. */ /** FIFO of pending host-key confirmations — render the head only. */
const [hostKeyQueue, setHostKeyQueue] = useState<HostKeyPromptEvent[]>([]) const [hostKeyQueue, setHostKeyQueue] = useState<QueuedHostKeyPrompt[]>([])
/** B's sidebar handle; refresh() after a successful SSH connect */ /** B's sidebar handle; refresh() after a successful SSH connect */
const sidebarRef = useRef<ConnectionSidebarHandle>(null) const sidebarRef = useRef<ConnectionSidebarHandle>(null)
@@ -647,21 +667,39 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
*/ */
useEffect(() => { useEffect(() => {
return window.api.onHostKeyPrompt((event: HostKeyPromptEvent) => { return window.api.onHostKeyPrompt((event: HostKeyPromptEvent) => {
setHostKeyQueue((prev) => [...prev, event]) setHostKeyQueue((prev) => [...prev, { ...event, enqueuedAt: Date.now() }])
}) })
}, []) }, [])
// A prompt the main process gave up on never gets a decision from the user:
// its own timeout resolves the attempt as a reject and stops tracking the
// prompt, but nothing tells the renderer. Since only the head is rendered,
// such an entry would hold the single visible slot forever and block every
// later host's prompt behind it, so sweep the expired ones out.
useEffect(() => {
const timer = window.setInterval(() => {
const now = Date.now()
setHostKeyQueue((prev) => {
const next = prev.filter((p) => now - p.enqueuedAt < HOST_KEY_PROMPT_TTL_MS)
return next.length === prev.length ? prev : next
})
}, HOST_KEY_SWEEP_MS)
return () => window.clearInterval(timer)
}, [])
const hostKeyHead = hostKeyQueue.length > 0 ? hostKeyQueue[0] : null const hostKeyHead = hostKeyQueue.length > 0 ? hostKeyQueue[0] : null
const handleHostKeyDecision = useCallback((action: 'accept' | 'reject'): void => { const handleHostKeyDecision = useCallback(
// The queue head's decision is final (accept/reject both consume the prompt). (promptId: string, action: 'accept' | 'reject'): void => {
setHostKeyQueue((prev) => { // The head's decision is final (accept/reject both consume the prompt).
const head = prev[0] // The IPC call belongs here, not inside the updater: React may re-run an
if (!head) return prev // updater (it does so deliberately to surface impure ones), which would
window.api.respondHostKey(head.promptId, action) // answer the prompt — and log it as unanswered — more than once.
return prev.slice(1) window.api.respondHostKey(promptId, action)
}) setHostKeyQueue((prev) => prev.filter((p) => p.promptId !== promptId))
}, []) },
[]
)
/** /**
* M6: Ctrl+PgUp / Ctrl+PgDn cycle tabs. Registered on `window` with * M6: Ctrl+PgUp / Ctrl+PgDn cycle tabs. Registered on `window` with
@@ -1032,77 +1070,119 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
const handleApplyTemplate = useCallback( const handleApplyTemplate = useCallback(
async (meta: LayoutMetaLike): Promise<void> => { async (meta: LayoutMetaLike): Promise<void> => {
const raw = await window.api.getLayout(meta.id) // Overlapping applies corrupt each other: the second call would collect
if (raw == null) return // the sessions the first one just restored into `previousSessions` and
// kill them on the way out. Drop the second call instead.
// Sessions currently bound to panels — the template load replaces them. if (applyTemplateInFlightRef.current) return
const previousSessions = new Set<string>() applyTemplateInFlightRef.current = true
for (const api of [terminalApiRef.current, sshApiRef.current]) {
if (!api) continue
for (const panel of api.panels) {
const sid = sessionIdOf(panel)
if (sid) previousSessions.add(sid)
}
}
let parsed: unknown
try { try {
parsed = JSON.parse(raw) const raw = await window.api.getLayout(meta.id)
} catch (error) { if (raw == null) return
console.error('[workspace] template payload is not valid JSON', meta.id, error)
message.error(t('workspace.template.applyFailed'))
return
}
const payload = parsed as { terminal?: unknown; ssh?: unknown }
const hasDualLayout = payload !== null && typeof payload === 'object' && 'terminal' in payload && 'ssh' in payload
// Snapshot both dockviews before they are touched: a payload that // Sessions currently bound to panels — the template load replaces them.
// deserializes half-way (a template written by another build, a panel const previousSessions = new Set<string>()
// whose component no longer exists) makes dockview clear every group and for (const api of [terminalApiRef.current, sshApiRef.current]) {
// panel it built *and* the layout it replaced, and only then rethrow if (!api) continue
// ("failed to deserialize layout. Reverting changes"). The snapshots are for (const panel of api.panels) {
// the way back to the layout the user had. const sid = sessionIdOf(panel)
const terminalSnapshot = terminalApiRef.current?.toJSON() if (sid) previousSessions.add(sid)
const sshSnapshot = sshApiRef.current?.toJSON() }
}
// Which dockview the payload reached — a failed load must not put a let parsed: unknown
// snapshot back into one it never touched: restoring a dockview wipes the try {
// panels that are still alive in it (and, through `onDidRemovePanel`, parsed = JSON.parse(raw)
// kills the sessions behind them). } catch (error) {
let terminalTouched = false console.error('[workspace] template payload is not valid JSON', meta.id, error)
let sshTouched = false message.error(t('workspace.template.applyFailed'))
return
}
const payload = parsed as { terminal?: unknown; ssh?: unknown }
const hasDualLayout = payload !== null && typeof payload === 'object' && 'terminal' in payload && 'ssh' in payload
// Dual layout (M6.1+): restore each dockview from its own payload. // Snapshot both dockviews before they are touched: a payload that
// Legacy layout (pre-M6.1 single toJSON): restore it wholly into the // deserializes half-way (a template written by another build, a panel
// terminal dockview. // whose component no longer exists) makes dockview clear every group and
try { // panel it built *and* the layout it replaced, and only then rethrow
if (hasDualLayout) { // ("failed to deserialize layout. Reverting changes"). The snapshots are
terminalTouched = true // the way back to the layout the user had.
terminalApiRef.current?.fromJSON(payload.terminal as never) const terminalSnapshot = terminalApiRef.current?.toJSON()
sshTouched = true const sshSnapshot = sshApiRef.current?.toJSON()
sshApiRef.current?.fromJSON(payload.ssh as never)
} else { // Which dockview the payload reached — a failed load must not put a
// Legacy single-dockview layout → restore into the terminal workspace. // snapshot back into one it never touched: restoring a dockview wipes the
terminalTouched = true // panels that are still alive in it (and, through `onDidRemovePanel`,
terminalApiRef.current?.fromJSON((payload as unknown) as never) // kills the sessions behind them).
let terminalTouched = false
let sshTouched = false
// Dual layout (M6.1+): restore each dockview from its own payload.
// Legacy layout (pre-M6.1 single toJSON): restore it wholly into the
// terminal dockview.
try {
if (hasDualLayout) {
terminalTouched = true
terminalApiRef.current?.fromJSON(payload.terminal as never)
sshTouched = true
sshApiRef.current?.fromJSON(payload.ssh as never)
} else {
// Legacy single-dockview layout → restore into the terminal workspace.
terminalTouched = true
terminalApiRef.current?.fromJSON((payload as unknown) as never)
}
} catch (error) {
console.error('[workspace] template apply failed', meta.id, error)
// The wipe took the panels down with it — and `onDidRemovePanel` took
// their sessions, so the user is left with an empty workspace and
// nothing to reattach to. Put each touched dockview back from its
// snapshot, then give every pane that comes back a fresh session: the
// one it used to show is gone.
if (terminalTouched && terminalSnapshot) {
restoreLayoutSnapshot(terminalApiRef.current, terminalSnapshot)
if (terminalApiRef.current) await rebindRestoredPanels(terminalApiRef.current)
}
if (sshTouched && sshSnapshot) {
restoreLayoutSnapshot(sshApiRef.current, sshSnapshot)
if (sshApiRef.current) await rebindRestoredPanels(sshApiRef.current)
}
// No session may outlive its panels: the dockview the payload never
// reached still shows its own (they stay), everything else goes.
const live = new Set<string>()
for (const api of [terminalApiRef.current, sshApiRef.current]) {
for (const panel of api?.panels ?? []) {
const sid = sessionIdOf(panel)
if (sid) live.add(sid)
}
}
for (const sid of previousSessions) if (!live.has(sid)) killSession(sid)
// The panel counters and the sidebar list describe the aborted load,
// not the layout that is back; rebuild both from the panels that are
// actually here.
recountAll()
recomputeLocalPanels()
const mode = useWorkspaceModeStore.getState().mode
activePanelRef.current = apiOfMode(mode, terminalApiRef.current, sshApiRef.current)?.activePanel
message.error(t('workspace.template.applyFailedRestored'))
return
} }
} catch (error) {
console.error('[workspace] template apply failed', meta.id, error) // Fresh sessions for every restored terminal panel. Only a dockview the
// The wipe took the panels down with it — and `onDidRemovePanel` took // payload actually reached may be rebound: `rebindRestoredPanels`
// their sessions, so the user is left with an empty workspace and // rewrites each panel's params to a fresh *local* session, so running it
// nothing to reattach to. Put each touched dockview back from its // over a legacy template's untouched ssh workspace would demote its live
// snapshot, then give every pane that comes back a fresh session: the // SSH panes to local terminals.
// one it used to show is gone. if (terminalApiRef.current) await rebindRestoredPanels(terminalApiRef.current)
if (terminalTouched && terminalSnapshot) { if (sshTouched && sshApiRef.current) await rebindRestoredPanels(sshApiRef.current)
restoreLayoutSnapshot(terminalApiRef.current, terminalSnapshot)
if (terminalApiRef.current) await rebindRestoredPanels(terminalApiRef.current) // A template carries the tab titles it was saved with, which may be in
} // another language (or written by a build whose language the user has
if (sshTouched && sshSnapshot) { // since switched away from).
restoreLayoutSnapshot(sshApiRef.current, sshSnapshot) retitleAutoPanels()
if (sshApiRef.current) await rebindRestoredPanels(sshApiRef.current)
} // Old sessions are unreachable after the layout swap — kill them, but
// No session may outlive its panels: the dockview the payload never // only the ones no panel shows any more: a legacy template never reached
// reached still shows its own (they stay), everything else goes. // the ssh dockview, whose panes keep their live SSH sessions (the same
// `live` test the failure path uses).
const live = new Set<string>() const live = new Set<string>()
for (const api of [terminalApiRef.current, sshApiRef.current]) { for (const api of [terminalApiRef.current, sshApiRef.current]) {
for (const panel of api?.panels ?? []) { for (const panel of api?.panels ?? []) {
@@ -1111,32 +1191,13 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
} }
} }
for (const sid of previousSessions) if (!live.has(sid)) killSession(sid) for (const sid of previousSessions) if (!live.has(sid)) killSession(sid)
// The panel counters and the sidebar list describe the aborted load,
// not the layout that is back; rebuild both from the panels that are
// actually here.
recountAll() recountAll()
recomputeLocalPanels() recomputeLocalPanels()
const mode = useWorkspaceModeStore.getState().mode const mode = useWorkspaceModeStore.getState().mode
activePanelRef.current = apiOfMode(mode, terminalApiRef.current, sshApiRef.current)?.activePanel activePanelRef.current = apiOfMode(mode, terminalApiRef.current, sshApiRef.current)?.activePanel
message.error(t('workspace.template.applyFailedRestored')) } finally {
return applyTemplateInFlightRef.current = false
} }
// Fresh sessions for every restored terminal panel in both workspaces.
if (terminalApiRef.current) await rebindRestoredPanels(terminalApiRef.current)
if (sshApiRef.current) await rebindRestoredPanels(sshApiRef.current)
// A template carries the tab titles it was saved with, which may be in
// another language (or written by a build whose language the user has
// since switched away from).
retitleAutoPanels()
// Old sessions are unreachable after the layout swap — kill them.
for (const sid of previousSessions) killSession(sid)
recountAll()
recomputeLocalPanels()
const mode = useWorkspaceModeStore.getState().mode
activePanelRef.current = apiOfMode(mode, terminalApiRef.current, sshApiRef.current)?.activePanel
}, },
[killSession, message, rebindRestoredPanels, recountAll, recomputeLocalPanels, retitleAutoPanels] [killSession, message, rebindRestoredPanels, recountAll, recomputeLocalPanels, retitleAutoPanels]
) )
@@ -1284,7 +1345,10 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
{/* Host-key verification — head of the FIFO queue only. */} {/* Host-key verification — head of the FIFO queue only. */}
{hostKeyHead != null && ( {hostKeyHead != null && (
<HostKeyModal event={hostKeyHead} onDecision={handleHostKeyDecision} /> <HostKeyModal
event={hostKeyHead}
onDecision={(action) => handleHostKeyDecision(hostKeyHead.promptId, action)}
/>
)} )}
<SaveTemplateModal <SaveTemplateModal