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:
1 parent
06cfc61392
commit
0289dcbd1d
1 file changed
+80
-16
@@ -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,6 +1070,12 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
|||||||
|
|
||||||
const handleApplyTemplate = useCallback(
|
const handleApplyTemplate = useCallback(
|
||||||
async (meta: LayoutMetaLike): Promise<void> => {
|
async (meta: LayoutMetaLike): Promise<void> => {
|
||||||
|
// Overlapping applies corrupt each other: the second call would collect
|
||||||
|
// the sessions the first one just restored into `previousSessions` and
|
||||||
|
// kill them on the way out. Drop the second call instead.
|
||||||
|
if (applyTemplateInFlightRef.current) return
|
||||||
|
applyTemplateInFlightRef.current = true
|
||||||
|
try {
|
||||||
const raw = await window.api.getLayout(meta.id)
|
const raw = await window.api.getLayout(meta.id)
|
||||||
if (raw == null) return
|
if (raw == null) return
|
||||||
|
|
||||||
@@ -1122,21 +1166,38 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fresh sessions for every restored terminal panel in both workspaces.
|
// Fresh sessions for every restored terminal panel. Only a dockview the
|
||||||
|
// payload actually reached may be rebound: `rebindRestoredPanels`
|
||||||
|
// rewrites each panel's params to a fresh *local* session, so running it
|
||||||
|
// over a legacy template's untouched ssh workspace would demote its live
|
||||||
|
// SSH panes to local terminals.
|
||||||
if (terminalApiRef.current) await rebindRestoredPanels(terminalApiRef.current)
|
if (terminalApiRef.current) await rebindRestoredPanels(terminalApiRef.current)
|
||||||
if (sshApiRef.current) await rebindRestoredPanels(sshApiRef.current)
|
if (sshTouched && sshApiRef.current) await rebindRestoredPanels(sshApiRef.current)
|
||||||
|
|
||||||
// A template carries the tab titles it was saved with, which may be in
|
// 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
|
// another language (or written by a build whose language the user has
|
||||||
// since switched away from).
|
// since switched away from).
|
||||||
retitleAutoPanels()
|
retitleAutoPanels()
|
||||||
|
|
||||||
// Old sessions are unreachable after the layout swap — kill them.
|
// Old sessions are unreachable after the layout swap — kill them, but
|
||||||
for (const sid of previousSessions) killSession(sid)
|
// only the ones no panel shows any more: a legacy template never reached
|
||||||
|
// the ssh dockview, whose panes keep their live SSH sessions (the same
|
||||||
|
// `live` test the failure path uses).
|
||||||
|
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)
|
||||||
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
|
||||||
|
} finally {
|
||||||
|
applyTemplateInFlightRef.current = false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[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
|
||||||
|
|||||||
Reference in new issue
Block a user