fix(main): PTY_EXIT 补偿查询、SSH 飞行连接登记与双窗口守卫

- 新增 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 背压钩子
  (见下一提交)
This commit is contained in:
Bill committed 2026-10-09 11:30:51 +08:00
1 parent 0289dcbd1d
commit 81ac112dac
9 files changed
+211 -12

No files matched your search

+4 -1
View File
@@ -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.
+4 -1
View File
@@ -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)
+135 -3
View File
@@ -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<string, number>()
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<string, PendingOpen>()
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,7 +322,15 @@ export async function openSession(opts: SessionOpenOptions, owner?: number): Pro
}
const conn = deps.getConnection(opts.connectionId)
const handle = await connectSsh(conn, opts.secretOverride, {
// 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) => {
@@ -272,8 +344,39 @@ export async function openSession(opts: SessionOpenOptions, owner?: number): Pro
},
knownHosts: deps.knownHosts,
broadcast: deps.broadcast,
promptHostKey: deps.promptHostKey
promptHostKey: deps.promptHostKey,
// Earliest reference to the client, so an abort can end it mid-handshake.
onClient: (client) => {
pending.client = client
}
})
} 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()
}
+14 -1
View File
@@ -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<string, PendingPrompt>()
/**
* 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,
+1
View File
@@ -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),
+1
View File
@@ -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,
+26 -5
View File
@@ -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
+9 -1
View File
@@ -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<SessionOpenResult>
/** output a session produced before this renderer subscribed (capped ring buffer) */
getSessionReplay(id: string): Promise<string>
/**
* 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<SessionStateResult>
writePty(id: string, data: string): void
resizePty(id: string, cols: number, rows: number): void
killPty(id: string): void
+17
View File
@@ -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