Files
orca/src/shared/ssh-pending-pty-kill.ts
T
Brennan Benson 6c4625d7ff fix(terminal): main records every tab close, and seeding reads the records (#22955)
* fix(terminal): every explicit terminal close commits through one main transaction

A renderer save cannot shrink terminal membership once main owns a repo's
topology, so desktop tab and pane closes, CLI split-pane closes and mobile
split-pane closes only became durable when the killed process's exit retired
the surface. A close whose kill failed or threw, or whose exit was never
certified, came back after a reload.

Every close now reaches closeTerminalSurface: the renderer sends an explicit
intent for user and cleanup closes, the CLI and mobile split-pane closes commit
the pane after their stop, and the headless and relayed mobile closes reuse the
same commit. A failed flush keeps the in-memory removal and no longer cancels
the kill. Exit retirement is unchanged.

* fix(terminal): tell the desktop renderer to drop a split pane main closed

A CLI or mobile close of one pane in a split commits the pane in main, but the
desktop kept showing it until reload when no exit arrived to remove it. The
close now sends a leaf-addressed notice: a mounted pane closes by leaf id, and
a parked tab collapses its stored layout. Addressing by leaf makes the notice
and the renderer's exit handling no-ops after each other, which replaces the
numeric pane-id notice that could close the whole tab when the exit won.

* fix(terminal): a pane close never widens into a whole-tab close

A leaf-addressed close fell through to the whole-tab close whenever main's layout no longer
held that leaf as one of several. Main's exit handling retires an exited split pane from the
saved layout, so closing that pane afterwards (the exited-pane overlay's Close, or a CLI close
whose stop delivers the exit first) removed the whole tab, live sibling included, and the
next renderer save could not restore it. A pane close is now a no-op unless its leaf is in a
multi-pane layout.

Also updates two mobile split-close assertions to expect the leaf-addressed notice, and adds a
test that a relayed mobile close of a renderer-listed tab still reaches the renderer's pin guard.

* test(terminal): cover the PTY-handle branch of a CLI split-pane close

The existing CLI split test resolves its handle through the renderer graph, so the branch
that closes a runtime-owned pane by its PTY handle had no test failing without its commit.

* fix(terminal): main records every terminal tab close, and seeding reads the records

* test(terminal): pin the renderer close mirror against an early SSH pull, and main's record writes against later saves

* fix(terminal): a CLI pane close with an unconfirmed stop closes only that pane

`orca terminal close <handle>` on one pane of a split used to close the
whole tab, live sibling included, whenever that pane's stop could not be
confirmed (for example an unreachable SSH host). An unconfirmed stop is
unverifiable, not a reason to drop siblings: the close now commits only
that leaf, tells the renderer to drop that leaf, and leaves the owed kill
to the controller's existing SSH pending-kill path.

On a host where no renderer lists the tab, main now also removes the
closed pane from the paired-client snapshot (with its retirement proof),
since no exit may arrive to do it.

* docs(terminal): correct the pull merge's record-safety comment

The mirror is written by closeTab before main commits, so the claim that only a
main-committed close writes a record was inaccurate; also reflow a split comment.

* refactor(terminal): one resolver decides whether a pane close becomes a tab close

Every explicit close now states its target as `{kind:'tab'}` or `{kind:'pane', leafId}`; no
optional leaf id silently means the whole tab. Main resolves a close it started in exactly one
place, reading the copy of the tab's panes its layout owner holds (the renderer-published layout
for tabs the desktop renderer lists, main's session layout otherwise). Only `last-pane` escalates,
through the existing tab path so the renderer's pin guard still runs; an unknown pane never widens.

- The CLI and phone paths drop their per-site sibling counts for the resolver.
- The notifier splits into a tab-only close and a leaf-addressed pane close.
- The headless tab closer takes a parent tab id, so a pane row cannot reach it.
- A phone close of one pane on a host with no desktop window now stops and closes only that pane.
- A phone close of one pane with no live process record closes that pane, not its tab.

* fix(cli): an unverifiable stop says the close happened

`orca terminal close` still exits 1 when the process stop cannot be verified, but its message now
says the terminal was closed and names the host's reason, instead of "close failed". It promises
that the kill retries on reconnect only when the SSH relay itself never answered the stop, the one
case a recorded kill order backs.

* fix(terminal): a phone pane close commits even when its kill fails

A paired client's close of one pane threw `terminal_close_failed` before committing anything when
the controller reported the kill failed, so the pane stayed. The kill is now best-effort, as it is
for a whole-tab close: the pane's removal always commits and the failure stays on the PTY's
liveness verdict.

* fix(terminal): a pane close widens only when a copy shows it is the last pane

The close resolver read an owner copy that records no panes as "the tab has
one pane", so a CLI close of one pane of a split, addressed while the
renderer listed the tab before publishing its panes, closed the whole tab.

Every copy now counts only if it records at least one pane, read in the
owner's order with the published rows as the last fallback, and a pane
close widens only when a copy lists that pane as the tab's only one. An
unsplit tab whose saved layout predates its pane still closes: its
published row names the pane.

* fix(cli): promise a kill retry only when the host recorded the kill

The close receipt inferred "the kill retries when the host reconnects" from
the stop reason's text, which a new transport message or a reworded error
would silently break.

An explicit close now records the replayable kill order when its stop goes
unconfirmed, before sending the follow-up kill (whose own failure is
recorded only once its RPC settles), and reports that on the receipt as an
optional `pendingKillRecorded`. The CLI promises the retry only from that
field, so an older host, which never sends it, gets no promise.

* test(pty): justify the controller cast the recorded-stop tests extend

* fix(terminal): parse the close target with typed narrowing

The low-evidence lint gate rejects Reflect.get and broad object parameters,
which failed static analysis. Narrow with 'in' checks instead and cover the
boundary parser's accept and reject cases.

* test(terminal): drive the close-record tests through the durable store

* fix(terminal): a desktop tab close is not refused by a split that bound while it waited

The renderer has already removed and killed a tab it closes, so its close intent now skips the
owner fence phone and CLI closes use. Before, a split pane whose binding was admitted between the
close request and its durable write made main refuse the close, and the tab came back on the next
launch whenever its processes did not exit.

* chore(terminal): note that closedByLayoutOwner goes away once main owns the terminal layout

* chore: take the base branch's lockfile, which a merge had reverted

* test(terminal): reload the close-intent fixture through the SQLite profile store

Main now requires a SQLite profile-state authority for a writable Store, so the save-and-reload
close tests build and reopen their store through the shared SQLite test harness.

* docs(terminal): state the close-record rule in the merge's active-workspace comment

* fix(terminal): the first close of a tab keeps its record, and every lookup honours the TTL

* test(terminal): give the relay reattach close record a recent close time

* fix(terminal): a close that removes a listed tab records itself; only an echo is skipped

The echo of a close main already made never finds the tab listed, and the close transaction already skips it when a live record exists. The record helper kept the first record as well, which only ever applied to a close that did remove a listed tab, and there it kept a stale reason and TTL.
2026-09-28 12:40:26 -07:00

163 lines
7.7 KiB
TypeScript

import type { SshRemotePtyLease } from './ssh-types'
/** A `pty.shutdown` this client issued to an SSH host and could not confirm.
*
* Why it must exist: a relay PTY is a child of the detached relay daemon, not of the ssh channel,
* so a shutdown that dies on the transport leaves a live shell — and often a live agent — running
* on the user's remote machine with nothing left to retry it. Marking the attempt `unverifiable`
* is correct but is not a fix; the kill is an intent that has to outlive the transport failure and
* be replayed against the authoritative host when contact returns.
*
* Carried on the existing `SshRemotePtyLease` rather than in a second journal: the lease is
* already the durable, restart-surviving, per-`(targetId, relayPtyId)` record of a remote PTY. */
export type SshPendingPtyKill = {
requestedAt: number
/** The host-minted PTY incarnation this kill was aimed at, and the fence.
*
* A legacy relay renumbers from `pty-1` on every start, so `(targetId, relayPtyId)` alone can
* name a DIFFERENT shell after a redeploy — the collision behind #16970. Current relays enforce
* this identity on `pty.shutdown`; the client also proves it from `pty.listProcesses` before
* replay so older relays that ignore the additive field keep the safest available fallback.
*
* Absent only for a `pty2:` id, whose per-start mint epoch makes the id itself the fence. */
incarnationId?: string
/** Replays attempted since. Diagnostic; the TTL, not this, is the bound. */
attempts: number
}
/** Whether a relay PTY id names exactly one process: current relays mint `pty2:<epoch>:<n>` with a
* fresh epoch per start, so the id never recurs. Legacy `pty-N` ids restart at 1. */
export function isEpochScopedRelayPtyId(relayPtyId: string): boolean {
return relayPtyId.startsWith('pty2:')
}
/** Colocated with the type so the two cannot drift. The lease loader is a strict whitelist that
* drops anything it does not name, so a record omitted here would be silently stripped on every
* launch — the exact failure `closed-terminal-tab-tombstones.ts` records having shipped once. */
export function normalizeSshPendingPtyKill(
value: unknown,
relayPtyId: string
): SshPendingPtyKill | null {
if (!value || typeof value !== 'object') {
return null
}
const raw = value as Partial<SshPendingPtyKill>
if (typeof raw.requestedAt !== 'number' || !Number.isFinite(raw.requestedAt)) {
return null
}
const attempts = typeof raw.attempts === 'number' && raw.attempts >= 0 ? raw.attempts : 0
if (raw.incarnationId === undefined && isEpochScopedRelayPtyId(relayPtyId)) {
return { requestedAt: raw.requestedAt, attempts }
}
// No fence, and an unfenced kill order is worse than none.
if (
typeof raw.incarnationId !== 'string' ||
!raw.incarnationId ||
raw.incarnationId.length > 128
) {
return null
}
return { requestedAt: raw.requestedAt, incarnationId: raw.incarnationId, attempts }
}
/** Backstop only — host acknowledgement is the normal exit. This covers a target the user never
* reconnects to, whose intent would otherwise be carried forever. Deliberately longer than the 7d
* ceiling on a relay grace window, so the intent outlives the longest window in which the host
* could still be holding the PTY it names. */
export const SSH_PENDING_PTY_KILL_TTL_MS = 30 * 24 * 60 * 60 * 1000
/** Per target, newest kept. A permanently unreachable host must not grow the store without bound. */
export const MAX_SSH_PENDING_PTY_KILLS_PER_TARGET = 200
/** The single definition of "too old to act on". Both the durable prune and the decision function
* read it, so a stale order cannot be replayed by one and kept by the other. */
export function isSshPendingPtyKillExpired(intent: SshPendingPtyKill, now: number): boolean {
return now - intent.requestedAt > SSH_PENDING_PTY_KILL_TTL_MS
}
/** What the authoritative host just said about this relay PTY id, from one `pty.listProcesses`. */
export type SshPendingPtyKillObservation = {
/** False only when the host answered and did not list the id: positive evidence of absence.
* A failed or timed-out listing is never expressed here — the caller defers instead. */
hostListsPty: boolean
/** The incarnation the host published for that id. `undefined` means the host published none,
* which reads as unknown — never as "no incarnation" and never as a match. */
hostIncarnationId: string | undefined
}
export type SshPendingPtyKillRetirement =
| 'host-reports-absent'
| 'relay-id-recycled'
| 'stop-confirmed'
export type SshPendingPtyKillDecision =
| { action: 'replay' }
| { action: 'retire'; reason: Exclude<SshPendingPtyKillRetirement, 'stop-confirmed'> }
| { action: 'defer'; reason: string }
/** Decides what to do with one recorded kill, given what the host just said about its id.
*
* The TTL is deliberately NOT a branch here. `isSshPendingPtyKillExpired` owns it, applied as a
* durable prune before any of this runs, so expired orders are actually deleted from the store
* rather than merely skipped — and so there is exactly one place that decides what "too old"
* means. A branch here would be unreachable behind that prune and would only look tested.
*
* `defer` is the `unverifiable` branch and asserts nothing: the record stays and the next
* handshake asks again. No branch concludes that a PTY exited — only `host-reports-absent` is an
* observation of absence, and it comes from the host that owns the process. */
export function decideSshPendingPtyKill(
intent: SshPendingPtyKill,
observation: SshPendingPtyKillObservation,
now: number
): SshPendingPtyKillDecision {
if (isSshPendingPtyKillExpired(intent, now)) {
// Unreachable behind the prune, but a stale order must never be aimed at whatever holds the
// id today if a future caller reaches this without pruning first.
return { action: 'defer', reason: 'order is past its TTL and awaiting prune' }
}
if (!observation.hostListsPty) {
return { action: 'retire', reason: 'host-reports-absent' }
}
if (intent.incarnationId === undefined) {
// The id is the fence: only an epoch-scoped id is ever recorded without an incarnation.
return { action: 'replay' }
}
if (observation.hostIncarnationId === undefined) {
// Why not replay: an unfenced kill against a renumbered relay id destroys a shell nobody asked
// to close, which is strictly worse than the leak. Hosts predating the published incarnation
// therefore degrade to no replay rather than to a guess.
return { action: 'defer', reason: 'host published no PTY incarnation for this id' }
}
if (observation.hostIncarnationId !== intent.incarnationId) {
return { action: 'retire', reason: 'relay-id-recycled' }
}
return { action: 'replay' }
}
export type SshPendingPtyKillEntry = { ptyId: string; intent: SshPendingPtyKill }
/** Newest-first, TTL-filtered and capped — the same shape as `pruneClosedTerminalTabTombstones`. */
export function prunePendingSshPtyKills(
entries: readonly SshPendingPtyKillEntry[],
now: number
): SshPendingPtyKillEntry[] {
return entries
.filter((entry) => !isSshPendingPtyKillExpired(entry.intent, now))
.sort((a, b) => b.intent.requestedAt - a.intent.requestedAt)
.slice(0, MAX_SSH_PENDING_PTY_KILLS_PER_TARGET)
}
/** Reads across every lease state on purpose: a kill issued while the provider was already gone
* tombstones its lease `terminated` and still leaves the remote process running. */
export function pendingSshPtyKillEntries(
leases: readonly SshRemotePtyLease[]
): SshPendingPtyKillEntry[] {
const entries: SshPendingPtyKillEntry[] = []
for (const lease of leases) {
if (lease.pendingKill) {
entries.push({ ptyId: lease.ptyId, intent: lease.pendingKill })
}
}
return entries
}