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.
|
// 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
@@ -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)
|
||||||
|
|||||||
+135
-3
@@ -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,7 +322,15 @@ 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
|
||||||
|
// 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: {
|
connections: {
|
||||||
getSecret: (c, field) => deps.getSecret(c, field),
|
getSecret: (c, field) => deps.getSecret(c, field),
|
||||||
touch: (id: string) => {
|
touch: (id: string) => {
|
||||||
@@ -272,8 +344,39 @@ export async function openSession(opts: SessionOpenOptions, owner?: number): Pro
|
|||||||
},
|
},
|
||||||
knownHosts: deps.knownHosts,
|
knownHosts: deps.knownHosts,
|
||||||
broadcast: deps.broadcast,
|
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 })
|
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
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user