From 81ac112dac5f1f393284c9e0bdcc553c4c65d066 Mon Sep 17 00:00:00 2001 From: Bill Date: Fri, 9 Oct 2026 11:30:51 +0800 Subject: [PATCH] =?UTF-8?q?fix(main):=20PTY=5FEXIT=20=E8=A1=A5=E5=81=BF?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E3=80=81SSH=20=E9=A3=9E=E8=A1=8C=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=E7=99=BB=E8=AE=B0=E4=B8=8E=E5=8F=8C=E7=AA=97=E5=8F=A3?= =?UTF-8?q?=E5=AE=88=E5=8D=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 SESSION_STATE 查询通道(契约先行):PTY_EXIT 只广播一次不重放, 绑定晚于退出的窗格订阅后补查一次退出状态,快速退出的 shell 不再留下 永远空白、无死亡遮罩的窗格(退出码登记表上限 512 条 FIFO) - openSession 在 connectSsh 返回前登记 pendingOpens(owner + onClient 最早引用),killPtysByOwner/killAllPtys 可中止飞行中的握手,renderer 崩溃后不再留下无主孤儿 SSH 会话 - whenReady 的 createWindow 加已有窗口守卫,堵住与 second-instance showOrCreate 的双窗口竞态 - killAllPtys 补 replayBuffers.clear(),与 killSession 拆卸清单对齐 - promptUser 注释 30s 改为实际 120s;pty.ts 顺带注入 zmodem 背压钩子 (见下一提交) --- src/main/index.ts | 5 +- src/main/ipc.ts | 5 +- src/main/pty.ts | 162 +++++++++++++++++++-- src/main/ssh.ts | 15 +- src/preload/index.ts | 1 + src/renderer/src/main.tsx | 1 + src/renderer/src/terminal/TerminalView.tsx | 31 +++- src/shared/api.ts | 10 +- src/shared/ipc.ts | 17 +++ 9 files changed, 223 insertions(+), 24 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 35021a9..6320653 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -100,7 +100,10 @@ if (!gotSingleInstanceLock) { // so the menu teardown is applied here from the flag itself. const lock = initLockController() applyMenuLockState(lock.isLocked()) - createWindow() + // A second launch that raced this startup already created (or surfaced) a + // window from its `second-instance` handler; creating another here would + // leave two. Same guard the activate handler below uses. + if (BrowserWindow.getAllWindows().length === 0) createWindow() initTray(showOrCreate) // Tray labels are resolved from the dictionary at build time, so the menu has // to be rebuilt whenever the interface language changes. diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 48af590..557a1a5 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -34,7 +34,7 @@ import { import { broadcast } from './broadcast' import { CommandsStore, defaultCommandsPath } from './commands' import type { CommandItem } from '../shared/commands' -import { createPty, killPty, resizePty, writePty, openSession, configureSessionRuntime, getSessionReplay, registerLogHooks } from './pty' +import { createPty, killPty, resizePty, writePty, openSession, configureSessionRuntime, getSessionReplay, sessionState, registerLogHooks } from './pty' import { respondZmodem } from './zmodem' import type { ZmodemResponse } from '../shared/ipc' @@ -153,6 +153,9 @@ export function registerIpc(): void { ipcMain.handle(Ipc.PTY_CREATE, (event, opts?: PtyCreateOptions) => createPty(opts, event.sender.id)) ipcMain.handle(Ipc.SESSION_OPEN, (event, opts: SessionOpenOptions) => openSession(opts, event.sender.id)) ipcMain.handle(Ipc.SESSION_REPLAY, (_event, id: string) => getSessionReplay(id)) + // Compensates the one-shot PTY_EXIT broadcast: a pane that subscribed after + // its shell died asks here instead of waiting forever. + ipcMain.handle(Ipc.SESSION_STATE, (_event, id: string) => sessionState(id)) ipcMain.on(Ipc.PTY_WRITE, (_event, id: string, data: string) => writePty(id, data)) ipcMain.on(Ipc.PTY_RESIZE, (_event, id: string, cols: number, rows: number) => resizePty(id, cols, rows) diff --git a/src/main/pty.ts b/src/main/pty.ts index 4955dbc..20e0fde 100644 --- a/src/main/pty.ts +++ b/src/main/pty.ts @@ -3,7 +3,7 @@ import { spawn, type IPty } from '@lydell/node-pty' import { randomUUID } from 'crypto' import { homedir } from 'os' import { StringDecoder } from 'string_decoder' -import { Ipc, type PtyCreateOptions, type PtyCreateResult } from '../shared/ipc' +import { Ipc, type PtyCreateOptions, type PtyCreateResult, type SessionStateResult } from '../shared/ipc' import type { SessionOpenOptions, HostKeyPromptEvent, SshConnection } from '../shared/connections' import { broadcast } from './broadcast' import { connectSsh, type SshSessionHandle } from './ssh' @@ -104,6 +104,68 @@ export function getSessionReplay(id: string): string { return replayBuffers.get(id) ?? '' } +/** + * Exit codes of sessions that are already gone. PTY_EXIT is broadcast exactly + * once and never replayed, so a pane that subscribes after its shell died + * would sit blank forever — the renderer compensates by querying SESSION_STATE + * once its subscription is in place. Insertion-ordered: the oldest entry is + * evicted first, and the cap only has to cover the panes that are still + * catching up. + */ +const MAX_SESSION_EXITS = 512 +const sessionExits = new Map() + +function rememberSessionExit(id: string, exitCode: number): void { + // Re-inserting keeps the map in "most recently exited last" order. + sessionExits.delete(id) + sessionExits.set(id, exitCode) + while (sessionExits.size > MAX_SESSION_EXITS) { + const oldest = sessionExits.keys().next().value + if (oldest === undefined) break + sessionExits.delete(oldest) + } +} + +/** + * What the main process knows about a session id: alive, exited (with its code), + * or unknown — a temp id from an aborted connect, or an evicted exit record. + */ +export function sessionState(id: string): SessionStateResult { + if (sessions.has(id)) return { exists: true, exited: false, exitCode: null } + const exitCode = sessionExits.get(id) + if (exitCode === undefined) return { exists: false, exited: false, exitCode: null } + return { exists: false, exited: true, exitCode } +} + +/** + * In-flight ssh connects, keyed by a temp id. Until `connectSsh` resolves there + * is no entry in `sessions`, so an owner that vanished during the handshake + * (renderer crash) or an app quit used to leave the connect running: it + * completed later and registered a session nobody owns. The attempt is + * registered up front so killPtysByOwner / killAllPtys can abort it — the + * client is ended, and openSession tears down whatever still comes back + * instead of adopting it. + */ +interface PendingOpen { + owner?: number + client?: SshSessionHandle['client'] + aborted: boolean +} + +const pendingOpens = new Map() + +function abortPendingOpens(matches: (pending: PendingOpen) => boolean): void { + for (const pending of pendingOpens.values()) { + if (!matches(pending)) continue + pending.aborted = true + try { + pending.client?.end() + } catch { + // best effort + } + } +} + /** * Dependencies injected once by ipc.ts (configureSessionRuntime) so pty.ts * stays free of store / known-hosts imports and the ssh service can remain @@ -230,6 +292,8 @@ export function createPty(opts: PtyCreateOptions = {}, owner?: number): PtyCreat // The session is gone: drop its replay buffer too (killPty was the only // path that did, so naturally-exiting shells leaked up to 64KB each). replayBuffers.delete(id) + // Remember the code for panes that subscribe after this broadcast. + rememberSessionExit(id, exitCode) safeStopLog(id) broadcast(Ipc.PTY_EXIT, { id, exitCode }) } catch { @@ -258,22 +322,61 @@ export async function openSession(opts: SessionOpenOptions, owner?: number): Pro } const conn = deps.getConnection(opts.connectionId) - const handle = await connectSsh(conn, opts.secretOverride, { - connections: { - getSecret: (c, field) => deps.getSecret(c, field), - touch: (id: string) => { - // Successful connect: record lastConnectedAt on the bookmark. - try { - deps.touch(id) - } catch { - // store write failure must not break the session + // Register the attempt before the handshake: until it resolves there is no + // session entry, so this is the only handle an owner-based kill has on it. + const tempId = randomUUID() + const pending: PendingOpen = { owner, aborted: false } + pendingOpens.set(tempId, pending) + + let handle: SshSessionHandle + try { + handle = await connectSsh(conn, opts.secretOverride, { + connections: { + getSecret: (c, field) => deps.getSecret(c, field), + touch: (id: string) => { + // Successful connect: record lastConnectedAt on the bookmark. + try { + deps.touch(id) + } catch { + // store write failure must not break the session + } } + }, + knownHosts: deps.knownHosts, + broadcast: deps.broadcast, + promptHostKey: deps.promptHostKey, + // Earliest reference to the client, so an abort can end it mid-handshake. + onClient: (client) => { + pending.client = client } - }, - knownHosts: deps.knownHosts, - broadcast: deps.broadcast, - promptHostKey: deps.promptHostKey - }) + }) + } catch (err) { + // An aborted attempt is our own teardown, not a connect failure: whoever + // asked for the session is already gone (owner crash / app quit), so the + // answer is the temp id — nothing was ever registered under it. + if (!pending.aborted) throw err + return { id: tempId } + } finally { + pendingOpens.delete(tempId) + } + + // The owner died while the handshake was in flight. Nothing has been + // registered yet, so the teardown is the transport alone — same order as + // killSession (stream first, then client) and no session-scoped cleanups. + if (pending.aborted) { + try { + handle.stream.close() + } catch { + // best effort + } + try { + handle.client.end() + } catch { + // best effort + } + return { id: tempId } + } + sessions.set(handle.id, { kind: 'ssh', ssh: handle, owner }) sshDecoders.set(handle.id, new StringDecoder('utf8')) @@ -301,6 +404,29 @@ export async function openSession(opts: SessionOpenOptions, owner?: number): Pro // stream may already be dead } } + }, + // Receive-side backpressure: pausing the ssh stream stops the bytes that + // feed the zmodem sentry, so a slow local disk cannot pile the whole + // transfer up in the fs WriteStream's unbounded buffer. + pauseSource: (id) => { + const s = sessions.get(id) + if (s?.kind === 'ssh') { + try { + s.ssh.stream.pause() + } catch { + // stream may already be dead + } + } + }, + resumeSource: (id) => { + const s = sessions.get(id) + if (s?.kind === 'ssh') { + try { + s.ssh.stream.resume() + } catch { + // stream may already be dead + } + } } }) @@ -333,6 +459,7 @@ export async function openSession(opts: SessionOpenOptions, owner?: number): Pro sessions.delete(handle.id) replayBuffers.delete(handle.id) sshDecoders.delete(handle.id) + rememberSessionExit(handle.id, exitCode) deps.broadcast(Ipc.PTY_EXIT, { id: handle.id, exitCode }) } catch { // never crash the event loop @@ -428,9 +555,13 @@ export function killPtysByOwner(owner: number): void { if (sessions.get(id)?.owner !== owner) continue killSession(id) } + // A connect that has not resolved yet has no session entry to match on, so it + // would finish later and register an orphan. + abortPendingOpens((pending) => pending.owner === owner) } export function killAllPtys(): void { + abortPendingOpens(() => true) for (const id of sessions.keys()) { forceStopPolling(id) closeSftp(id) @@ -458,5 +589,6 @@ export function killAllPtys(): void { } } sessions.clear() + replayBuffers.clear() sshDecoders.clear() } \ No newline at end of file diff --git a/src/main/ssh.ts b/src/main/ssh.ts index 8726469..b2a423d 100644 --- a/src/main/ssh.ts +++ b/src/main/ssh.ts @@ -64,6 +64,13 @@ export interface SshServiceDeps { fingerprint: string reason: 'new' | 'changed' }): void + /** + * Called with the ssh2 client as soon as it exists — the earliest moment it + * can be ended. pty.ts keeps the reference so an owner crash / app quit + * during the handshake can abort the connect instead of letting it finish and + * register a session nobody owns. + */ + onClient?: (client: Client) => void timeoutMs?: { prompt: number; connect: number } } @@ -188,6 +195,12 @@ export async function connectSsh( // failure paths before resolution: `fail` and the connect timer armConnectTimer() + // Hand out the client before anything is dialled, so an abort during the + // handshake has a reference to end(). end() is a no-op until connect() + // created the socket, but the executor runs synchronously, so connect() + // below is already under way before openSession regains control. + deps.onClient?.(handshake) + handshake.on('error', (err: Error) => { console.error(`[ssh] client error: ${err.message}`) const message = verifierErr ?? err.message @@ -315,7 +328,7 @@ const pendingPrompts = new Map() /** * Ask the renderer to approve / reject a host key. Resolves `true`/`false`. - * Times out (default 30s) -> treated as reject. + * Times out (default 120s) -> treated as reject. */ function promptUser( host: string, diff --git a/src/preload/index.ts b/src/preload/index.ts index fa2dcee..5996c30 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -19,6 +19,7 @@ const api: AppApi = { createPty: (opts?: PtyCreateOptions) => ipcRenderer.invoke(Ipc.PTY_CREATE, opts), openSession: (opts: SessionOpenOptions) => ipcRenderer.invoke(Ipc.SESSION_OPEN, opts), getSessionReplay: (id: string) => ipcRenderer.invoke(Ipc.SESSION_REPLAY, id), + getSessionLiveState: (id: string) => ipcRenderer.invoke(Ipc.SESSION_STATE, id), writePty: (id: string, data: string) => ipcRenderer.send(Ipc.PTY_WRITE, id, data), resizePty: (id: string, cols: number, rows: number) => ipcRenderer.send(Ipc.PTY_RESIZE, id, cols, rows), diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index a4d6f61..fe609fb 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -52,6 +52,7 @@ if (typeof window !== 'undefined' && !window.api) { createPty: async () => ({ id: stubId(), shell: 'stub', cwd: '' }), openSession: async () => ({ id: stubId() }), getSessionReplay: async () => '', + getSessionLiveState: async () => ({ exists: false, exited: false, exitCode: null }), writePty: noop, resizePty: noop, killPty: noop, diff --git a/src/renderer/src/terminal/TerminalView.tsx b/src/renderer/src/terminal/TerminalView.tsx index ae15ea3..30730fa 100644 --- a/src/renderer/src/terminal/TerminalView.tsx +++ b/src/renderer/src/terminal/TerminalView.tsx @@ -1042,6 +1042,13 @@ export function TerminalView({ // Live data is queued until the replay lands so output keeps its order. let replayDone = false const pending: string[] = [] + // Death state, shared by the PTY_EXIT subscription and the SESSION_STATE + // query below: the two paths must land identically. + const markDead = (code: number): void => { + deadRef.current = true + setExitCode(code) + setDead(true) + } const unsubscribes: (() => void)[] = [ // Routed via the shared dispatcher (one IPC listener for all panes) // instead of a per-pane global listener. @@ -1053,11 +1060,7 @@ export function TerminalView({ } writeHighlighted(data) }), - subscribePtyExit(sessionId, (code: number) => { - deadRef.current = true - setExitCode(code) - setDead(true) - }) + subscribePtyExit(sessionId, markDead) ] // Late-subscriber catch-up: output emitted before this subscription // (shell banner, template-apply rebind) replays from the main buffer. @@ -1073,6 +1076,21 @@ export function TerminalView({ pending.length = 0 }) + // PTY_EXIT is broadcast once and never replayed, so a shell that died in + // the gap between openSession and this subscription (or before the pane was + // rebound to it) would leave the pane blank forever. Ask once, now that the + // subscription can no longer miss it. `disposed` rather than deadRef: the + // cleanup below also runs on a rebind, where the pane is alive again under + // another session and this answer is stale. + let disposed = false + void window.api + .getSessionLiveState(sessionId) + .then((state) => { + if (disposed || !state.exited) return + markDead(state.exitCode ?? 0) + }) + .catch(() => undefined) + let observer: ResizeObserver | undefined if (hostRef.current) { observer = new ResizeObserver(() => scheduleFit()) @@ -1114,6 +1132,9 @@ export function TerminalView({ for (const disposable of disposables) disposable.dispose() observer?.disconnect() observerRef.current = null + // The pane is unmounted or rebinding to another session: an in-flight + // SESSION_STATE answer must not mark the next session dead. + disposed = true deadRef.current = true webglRef.current?.dispose() webglRef.current = null diff --git a/src/shared/api.ts b/src/shared/api.ts index 8950bec..2849379 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -6,7 +6,8 @@ import type { PtyDataEvent, PtyExitEvent, SessionOpenResult, - SessionSnapshot + SessionSnapshot, + SessionStateResult } from './ipc' import type { AppSettings } from './settings' import type { ReleaseNote, UpdateState } from './ipc' @@ -36,6 +37,13 @@ export interface AppApi { openSession(opts: SessionOpenOptions): Promise /** output a session produced before this renderer subscribed (capped ring buffer) */ getSessionReplay(id: string): Promise + /** + * Compensating query for a missed PTY_EXIT: that broadcast fires exactly once + * and is never replayed, so a pane subscribing after its shell died would + * wait forever. `exited` (with `exitCode`) is the death state PTY_EXIT would + * have set. + */ + getSessionLiveState(id: string): Promise writePty(id: string, data: string): void resizePty(id: string, cols: number, rows: number): void killPty(id: string): void diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 60deb1a..a1a83eb 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -35,6 +35,13 @@ export const Ipc = { SESSION_OPEN: 'session:open', /** main keeps a short replay buffer per session so late subscribers catch up */ SESSION_REPLAY: 'session:replay', + /** + * Compensating query for a missed PTY_EXIT: that broadcast fires exactly once + * and is never replayed, so a pane bound to a shell that died before it + * subscribed would wait forever. Distinct from SESSION_STATE_GET/SET, which + * carry the layout snapshot. + */ + SESSION_STATE: 'session:state', // ---- ssh connections (bookmarks) ---- CONNECTIONS_LIST: 'connections:list', @@ -216,6 +223,16 @@ export interface PtyExitEvent { exitCode: number } +/** Answer to SESSION_STATE: what the main process still knows about a session. */ +export interface SessionStateResult { + /** the session is alive and still accepts data */ + exists: boolean + /** the session exited; `exitCode` then carries the status it died with */ + exited: boolean + /** null unless `exited` */ + exitCode: number | null +} + export interface AppInfo { platform: NodeJS.Platform | string appVersion: string