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:
1 parent
0289dcbd1d
commit
81ac112dac
9 files changed
+211
-12
No files matched your search
+4
-1
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user