mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 16:02:56 +00:00
* fix(ssh): replay an undelivered remote PTY stop on the next handshake A pty.shutdown that dies on the transport left the remote shell running forever: kill.ts marked liveness unverifiable and nothing retried. Record the undelivered stop on the existing durable SshRemotePtyLease and replay it against the authoritative host on the next handshake to that same target, fenced by the host-minted PTY incarnation so a replay cannot kill a later PTY that reused a recycled pty-N id. Retire the record on confirmed delivery, on the host reporting the PTY absent, and on a bounded TTL. No wire change: the fence reads incarnationId, already published on pty.listProcesses. A host that does not publish it degrades to no replay. * fix(ssh): do not leave a replayable kill order behind a reversible stop Worktree sleep stops through stopAndWait and marks those stops reversible; when one does not land the pane stays live and the user keeps using it. An order recorded there would come back on a later handshake and kill that terminal. Only killPtyFromRuntimeController — where the client gives the PTY up for good — records one, and it skips any PTY a reversible stop owns. * fix(ssh): cover the renderer kill route and harden the replay's evidence pty:kill is a separate implementation from killPtyFromRuntimeController and is the one an ordinary tab close reaches, so the record was never written on the path #12447 describes. Extracted it out of inspect.ts (which was over the line budget and was not what the file is named for) and wired both branches. Also: - finishPtyShutdown no longer retires the order. It runs on paths that asked the host and on paths that never did, so retiring there was a contract every caller had to know, and the one that forgot silently dropped a kill order. Retirement is the replay's, on inventory evidence only. - A recycled relay id now expires its lease. Declining to kill was only half: reattach fences on paneKey/tabId, never incarnation, so an untouched lease bound the user's old pane to whatever now holds the id. - Dropped isPtyAlreadyGoneError from the tombstone path. It matches message text a transport failure could wear; every tombstone now traces to a listing. - TTL is owned by a durable prune that actually deletes, not by a branch that was unreachable behind the read filter and only looked tested. - The replay re-reads the inventory per wave and re-checks the fence next to each shutdown, and can never reject into the connect path.
85 lines
3.3 KiB
TypeScript
85 lines
3.3 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
decideSshPendingPtyKill,
|
|
MAX_SSH_PENDING_PTY_KILLS_PER_TARGET,
|
|
prunePendingSshPtyKills,
|
|
SSH_PENDING_PTY_KILL_TTL_MS,
|
|
type SshPendingPtyKill,
|
|
type SshPendingPtyKillEntry
|
|
} from './ssh-pending-pty-kill'
|
|
|
|
const NOW = 1_800_000_000_000
|
|
|
|
function intent(overrides: Partial<SshPendingPtyKill> = {}): SshPendingPtyKill {
|
|
return { requestedAt: NOW, incarnationId: 'inc-a', attempts: 0, ...overrides }
|
|
}
|
|
|
|
describe('decideSshPendingPtyKill', () => {
|
|
it('replays only when the host still holds the exact incarnation the kill was aimed at', () => {
|
|
expect(
|
|
decideSshPendingPtyKill(intent(), { hostListsPty: true, hostIncarnationId: 'inc-a' }, NOW)
|
|
).toEqual({ action: 'replay' })
|
|
})
|
|
|
|
// The #16970 collision: a redeployed relay renumbers from pty-1, so the same id can name a
|
|
// different shell. Replaying here would kill a terminal nobody asked to close.
|
|
it('retires without killing when the relay id was recycled onto another PTY', () => {
|
|
expect(
|
|
decideSshPendingPtyKill(intent(), { hostListsPty: true, hostIncarnationId: 'inc-b' }, NOW)
|
|
).toEqual({ action: 'retire', reason: 'relay-id-recycled' })
|
|
})
|
|
|
|
it('retires when the owning host answers and does not list the PTY', () => {
|
|
expect(
|
|
decideSshPendingPtyKill(
|
|
intent(),
|
|
{ hostListsPty: false, hostIncarnationId: undefined },
|
|
NOW + 1000
|
|
)
|
|
).toEqual({ action: 'retire', reason: 'host-reports-absent' })
|
|
})
|
|
|
|
// TTL retirement belongs to the durable prune, not to a branch here — a branch would be
|
|
// unreachable behind it and would only look tested. This guards the belt-and-braces refusal:
|
|
// an expired order that somehow reaches this function is never dispatched.
|
|
it('refuses to replay past the TTL, ahead of every other branch', () => {
|
|
const stale = intent()
|
|
const later = NOW + SSH_PENDING_PTY_KILL_TTL_MS + 1
|
|
expect(
|
|
decideSshPendingPtyKill(stale, { hostListsPty: true, hostIncarnationId: 'inc-a' }, later)
|
|
.action
|
|
).toBe('defer')
|
|
expect(
|
|
decideSshPendingPtyKill(stale, { hostListsPty: true, hostIncarnationId: 'inc-a' }, NOW + 1)
|
|
).toEqual({ action: 'replay' })
|
|
})
|
|
|
|
// Wire skew: a host predating the published PTY incarnation leaves the fence unanswerable.
|
|
// Absence must read as unknown, never as a match and never as a recycle.
|
|
it('defers rather than guessing when the host published no incarnation', () => {
|
|
expect(
|
|
decideSshPendingPtyKill(intent(), { hostListsPty: true, hostIncarnationId: undefined }, NOW)
|
|
.action
|
|
).toBe('defer')
|
|
})
|
|
})
|
|
|
|
describe('prunePendingSshPtyKills', () => {
|
|
it('drops expired entries and caps the rest newest-first', () => {
|
|
const entries: SshPendingPtyKillEntry[] = [
|
|
{
|
|
ptyId: 'pty-stale',
|
|
intent: intent({ requestedAt: NOW - SSH_PENDING_PTY_KILL_TTL_MS - 1 })
|
|
},
|
|
...Array.from({ length: MAX_SSH_PENDING_PTY_KILLS_PER_TARGET + 25 }, (_, index) => ({
|
|
ptyId: `pty-${index}`,
|
|
intent: intent({ requestedAt: NOW - index })
|
|
}))
|
|
]
|
|
const pruned = prunePendingSshPtyKills(entries, NOW)
|
|
expect(pruned).toHaveLength(MAX_SSH_PENDING_PTY_KILLS_PER_TARGET)
|
|
expect(pruned.map((entry) => entry.ptyId)).not.toContain('pty-stale')
|
|
expect(pruned[0]?.ptyId).toBe('pty-0')
|
|
})
|
|
})
|