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
+223 -24

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. // so the menu teardown is applied here from the flag itself.
const lock = initLockController() const lock = initLockController()
applyMenuLockState(lock.isLocked()) 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) initTray(showOrCreate)
// Tray labels are resolved from the dictionary at build time, so the menu has // Tray labels are resolved from the dictionary at build time, so the menu has
// to be rebuilt whenever the interface language changes. // to be rebuilt whenever the interface language changes.
+4 -1
View File
@@ -34,7 +34,7 @@ import {
import { broadcast } from './broadcast' import { broadcast } from './broadcast'
import { CommandsStore, defaultCommandsPath } from './commands' import { CommandsStore, defaultCommandsPath } from './commands'
import type { CommandItem } from '../shared/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 { respondZmodem } from './zmodem'
import type { ZmodemResponse } from '../shared/ipc' 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.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_OPEN, (event, opts: SessionOpenOptions) => openSession(opts, event.sender.id))
ipcMain.handle(Ipc.SESSION_REPLAY, (_event, id: string) => getSessionReplay(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_WRITE, (_event, id: string, data: string) => writePty(id, data))
ipcMain.on(Ipc.PTY_RESIZE, (_event, id: string, cols: number, rows: number) => ipcMain.on(Ipc.PTY_RESIZE, (_event, id: string, cols: number, rows: number) =>
resizePty(id, cols, rows) resizePty(id, cols, rows)
+147 -15
View File
@@ -3,7 +3,7 @@ import { spawn, type IPty } from '@lydell/node-pty'
import { randomUUID } from 'crypto' import { randomUUID } from 'crypto'
import { homedir } from 'os' import { homedir } from 'os'
import { StringDecoder } from 'string_decoder' 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 type { SessionOpenOptions, HostKeyPromptEvent, SshConnection } from '../shared/connections'
import { broadcast } from './broadcast' import { broadcast } from './broadcast'
import { connectSsh, type SshSessionHandle } from './ssh' import { connectSsh, type SshSessionHandle } from './ssh'
@@ -104,6 +104,68 @@ export function getSessionReplay(id: string): string {
return replayBuffers.get(id) ?? '' 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 * Dependencies injected once by ipc.ts (configureSessionRuntime) so pty.ts
* stays free of store / known-hosts imports and the ssh service can remain * 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 // 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). // path that did, so naturally-exiting shells leaked up to 64KB each).
replayBuffers.delete(id) replayBuffers.delete(id)
// Remember the code for panes that subscribe after this broadcast.
rememberSessionExit(id, exitCode)
safeStopLog(id) safeStopLog(id)
broadcast(Ipc.PTY_EXIT, { id, exitCode }) broadcast(Ipc.PTY_EXIT, { id, exitCode })
} catch { } catch {
@@ -258,22 +322,61 @@ export async function openSession(opts: SessionOpenOptions, owner?: number): Pro
} }
const conn = deps.getConnection(opts.connectionId) 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
connections: { // session entry, so this is the only handle an owner-based kill has on it.
getSecret: (c, field) => deps.getSecret(c, field), const tempId = randomUUID()
touch: (id: string) => { const pending: PendingOpen = { owner, aborted: false }
// Successful connect: record lastConnectedAt on the bookmark. pendingOpens.set(tempId, pending)
try {
deps.touch(id) let handle: SshSessionHandle
} catch { try {
// store write failure must not break the session 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, } catch (err) {
broadcast: deps.broadcast, // An aborted attempt is our own teardown, not a connect failure: whoever
promptHostKey: deps.promptHostKey // 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 }) sessions.set(handle.id, { kind: 'ssh', ssh: handle, owner })
sshDecoders.set(handle.id, new StringDecoder('utf8')) 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 // 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) sessions.delete(handle.id)
replayBuffers.delete(handle.id) replayBuffers.delete(handle.id)
sshDecoders.delete(handle.id) sshDecoders.delete(handle.id)
rememberSessionExit(handle.id, exitCode)
deps.broadcast(Ipc.PTY_EXIT, { id: handle.id, exitCode }) deps.broadcast(Ipc.PTY_EXIT, { id: handle.id, exitCode })
} catch { } catch {
// never crash the event loop // never crash the event loop
@@ -428,9 +555,13 @@ export function killPtysByOwner(owner: number): void {
if (sessions.get(id)?.owner !== owner) continue if (sessions.get(id)?.owner !== owner) continue
killSession(id) 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 { export function killAllPtys(): void {
abortPendingOpens(() => true)
for (const id of sessions.keys()) { for (const id of sessions.keys()) {
forceStopPolling(id) forceStopPolling(id)
closeSftp(id) closeSftp(id)
@@ -458,5 +589,6 @@ export function killAllPtys(): void {
} }
} }
sessions.clear() sessions.clear()
replayBuffers.clear()
sshDecoders.clear() sshDecoders.clear()
} }
+14 -1
View File
@@ -64,6 +64,13 @@ export interface SshServiceDeps {
fingerprint: string fingerprint: string
reason: 'new' | 'changed' reason: 'new' | 'changed'
}): void }): 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 } timeoutMs?: { prompt: number; connect: number }
} }
@@ -188,6 +195,12 @@ export async function connectSsh(
// failure paths before resolution: `fail` and the connect timer // failure paths before resolution: `fail` and the connect timer
armConnectTimer() 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) => { handshake.on('error', (err: Error) => {
console.error(`[ssh] client error: ${err.message}`) console.error(`[ssh] client error: ${err.message}`)
const message = verifierErr ?? 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`. * 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( function promptUser(
host: string, host: string,
+1
View File
@@ -19,6 +19,7 @@ const api: AppApi = {
createPty: (opts?: PtyCreateOptions) => ipcRenderer.invoke(Ipc.PTY_CREATE, opts), createPty: (opts?: PtyCreateOptions) => ipcRenderer.invoke(Ipc.PTY_CREATE, opts),
openSession: (opts: SessionOpenOptions) => ipcRenderer.invoke(Ipc.SESSION_OPEN, opts), openSession: (opts: SessionOpenOptions) => ipcRenderer.invoke(Ipc.SESSION_OPEN, opts),
getSessionReplay: (id: string) => ipcRenderer.invoke(Ipc.SESSION_REPLAY, id), 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), writePty: (id: string, data: string) => ipcRenderer.send(Ipc.PTY_WRITE, id, data),
resizePty: (id: string, cols: number, rows: number) => resizePty: (id: string, cols: number, rows: number) =>
ipcRenderer.send(Ipc.PTY_RESIZE, id, cols, rows), 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: '' }), createPty: async () => ({ id: stubId(), shell: 'stub', cwd: '' }),
openSession: async () => ({ id: stubId() }), openSession: async () => ({ id: stubId() }),
getSessionReplay: async () => '', getSessionReplay: async () => '',
getSessionLiveState: async () => ({ exists: false, exited: false, exitCode: null }),
writePty: noop, writePty: noop,
resizePty: noop, resizePty: noop,
killPty: 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. // Live data is queued until the replay lands so output keeps its order.
let replayDone = false let replayDone = false
const pending: string[] = [] 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)[] = [ const unsubscribes: (() => void)[] = [
// Routed via the shared dispatcher (one IPC listener for all panes) // Routed via the shared dispatcher (one IPC listener for all panes)
// instead of a per-pane global listener. // instead of a per-pane global listener.
@@ -1053,11 +1060,7 @@ export function TerminalView({
} }
writeHighlighted(data) writeHighlighted(data)
}), }),
subscribePtyExit(sessionId, (code: number) => { subscribePtyExit(sessionId, markDead)
deadRef.current = true
setExitCode(code)
setDead(true)
})
] ]
// Late-subscriber catch-up: output emitted before this subscription // Late-subscriber catch-up: output emitted before this subscription
// (shell banner, template-apply rebind) replays from the main buffer. // (shell banner, template-apply rebind) replays from the main buffer.
@@ -1073,6 +1076,21 @@ export function TerminalView({
pending.length = 0 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 let observer: ResizeObserver | undefined
if (hostRef.current) { if (hostRef.current) {
observer = new ResizeObserver(() => scheduleFit()) observer = new ResizeObserver(() => scheduleFit())
@@ -1114,6 +1132,9 @@ export function TerminalView({
for (const disposable of disposables) disposable.dispose() for (const disposable of disposables) disposable.dispose()
observer?.disconnect() observer?.disconnect()
observerRef.current = null 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 deadRef.current = true
webglRef.current?.dispose() webglRef.current?.dispose()
webglRef.current = null webglRef.current = null
+9 -1
View File
@@ -6,7 +6,8 @@ import type {
PtyDataEvent, PtyDataEvent,
PtyExitEvent, PtyExitEvent,
SessionOpenResult, SessionOpenResult,
SessionSnapshot SessionSnapshot,
SessionStateResult
} from './ipc' } from './ipc'
import type { AppSettings } from './settings' import type { AppSettings } from './settings'
import type { ReleaseNote, UpdateState } from './ipc' import type { ReleaseNote, UpdateState } from './ipc'
@@ -36,6 +37,13 @@ export interface AppApi {
openSession(opts: SessionOpenOptions): Promise<SessionOpenResult> openSession(opts: SessionOpenOptions): Promise<SessionOpenResult>
/** output a session produced before this renderer subscribed (capped ring buffer) */ /** output a session produced before this renderer subscribed (capped ring buffer) */
getSessionReplay(id: string): Promise<string> 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 writePty(id: string, data: string): void
resizePty(id: string, cols: number, rows: number): void resizePty(id: string, cols: number, rows: number): void
killPty(id: string): void killPty(id: string): void
+17
View File
@@ -35,6 +35,13 @@ export const Ipc = {
SESSION_OPEN: 'session:open', SESSION_OPEN: 'session:open',
/** main keeps a short replay buffer per session so late subscribers catch up */ /** main keeps a short replay buffer per session so late subscribers catch up */
SESSION_REPLAY: 'session:replay', 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) ---- // ---- ssh connections (bookmarks) ----
CONNECTIONS_LIST: 'connections:list', CONNECTIONS_LIST: 'connections:list',
@@ -216,6 +223,16 @@ export interface PtyExitEvent {
exitCode: number 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 { export interface AppInfo {
platform: NodeJS.Platform | string platform: NodeJS.Platform | string
appVersion: string appVersion: string