Files
orca/src/main/hook-archive-timeout-observation.test.ts
T
Neil 3763103084 fix(hooks): report a timed-out hook as unverifiable and terminate its process tree (#20559)
## In plain terms

Orca lets a project define scripts that run at certain moments — one when a workspace is set up,
one just before it is deleted. Those scripts get a time limit. When the limit ran out, Orca asked
the script to stop and then believed whatever the script said on its way out — so a script written
to shut down politely could be cut off halfway through its work and still report that it had
finished. Anything relying on that answer was relying on a guess.

Now the verdict comes from the clock, not from the script: if it ran out of time, that is what is
reported, whatever exit code it managed on the way out. Orca also stops the script's *children*
rather than just the script, so a background process it started can no longer outlive it.

Split out of #20153 so the gate that consumes this answer is reviewed separately. `Refs #19334`
rather than `Fixes`, because it does not close the issue on its own.

## The bug

`exec({ timeout })` sends SIGTERM and then reports what the child did. A hook that traps SIGTERM
and exits 0 therefore comes back with a **null error** — success — despite having been cut off.

```js
exec("trap 'exit 0' TERM; sleep 5", { timeout: 200 }, (err) => …)  // err === null
```

That is not an `exited` vs `unverifiable` nicety: it is a failed hook reported as a passing one.
Realistic triggers are ordinary — a Node wrapper with a graceful `process.on('SIGTERM')`, an rsync
wrapper that cleans up on signal.

## What changed

**`runHook` owns the deadline.** The verdict comes from running out of time rather than from the
corpse's exit code, and it is settled *at* the deadline rather than whenever the child gets around
to dying — a hook that traps the signal and keeps running must not hold its caller open.

**A timeout withholds the exit code.** So does a spawn failure, where `exec` reports a *string*
code (`ENOENT`); the `typeof code === 'number'` guard is what keeps a hook that never ran out of the
"exited" verdict. Callers that distinguish "exited N" from "outcome never observed" can now trust
that distinction:

| failure mode | `error.code` | signal | verdict |
| --- | --- | --- | --- |
| non-zero exit | `23` | — | `exited 23` |
| command not found | `127` | — | `exited 127` |
| killed | `null` | SIGKILL | outcome not observed |
| deadline expired | *(the deadline, not the exit)* | SIGTERM→SIGKILL | outcome not observed |
| deadline expired, hook traps SIGTERM and exits 0 | `0` | — | outcome not observed |
| spawn failure | `"ENOENT"` *(string)* | — | outcome not observed |

**Termination reaches the process group.** The script is a shell and the work is its children, so
signalling only the shell leaves a `sleep` or an `rsync` alive holding the pipes open. SIGTERM
first, then SIGKILL after a grace.

**One `classifyHookProcessResult`** now serves the native and WSL branches, which had been mapping a
finished process to a hook verdict by hand, identically. That duplication predates this change.

## Terminating the tree, and a test that could not fail

The escalation went wrong once in review, in a way worth recording.

A first attempt skipped the SIGKILL when the *direct child* had already exited — a dead child needs
no signal. That is correct about the child and wrong about the group: a hook that backgrounds a
server typically loses its shell leader to the first SIGTERM while the server keeps running, so the
skip fired in exactly the case the escalation exists for. The escalation now probes the **group**
with signal 0: `ESRCH` means nothing is left to kill, anything else gets the signal.

**The residual trade-off, stated rather than implied.** Signalling by negative pid names whatever
group owns that pid *now*. Once the leader is reaped its pid can be recycled, and a probe cannot
distinguish a surviving descendant from a stranger that inherited the number. Killing a runaway hook
is both the likelier event and the one the deadline promises, so the group is signalled whenever it
answers; the remaining window is pid wraparound inside the grace.

**A test that cannot fail is worse than no test.** The first regression test drove `runHook` with
`process.kill` intercepted — and passed against *both* the broken and the fixed version, because
with signals intercepted nothing dies, so the child never reached the exited state the bad guard
keyed on. It was false assurance, not coverage. `terminateHookTree` is therefore exported and the
regression pinned directly against it: it fails on the old version
(`expected [] to deeply equal [[-4242, 'SIGKILL']]`) and passes on this one.

## Behaviour change for `setup` hooks

Both hook kinds share `runHook`, so this is not confined to archive hooks. **A setup hook that
backgrounds a long-running server now has that server SIGTERM'd — then SIGKILL'd — with the rest of
its process group when the deadline expires, where previously it was orphaned and survived.**
Arguably the better behaviour, since an orphaned server is a leak, but it is a real change and
should be a decision rather than a discovery.

## Evidence

Against real shells and real signals, because this bug is invisible to a mock
(`hook-archive-timeout-observation.test.ts`, through `runHook` itself rather than an extracted
helper):

```
✓ fails a hook that traps SIGTERM and exits zero, despite its zero exit
✓ settles at the deadline even when the hook refuses to die
✓ passes a hook that finishes inside its deadline
✓ reports an observed non-zero exit as the exit it is
```

Plus `hooks-archive-exit-observation.test.ts` for the wiring — including the string-`ENOENT` case —
and `hook-archive-termination-safety.test.ts` for the escalation branching.

## Checks

`pnpm tc` · `oxlint src` · `oxfmt --check` · 113 tests across `src/main/hooks*`. The classification
table above is measured against real `exec`, not reasoned.
2026-09-13 22:36:01 -07:00

53 lines
2.4 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { Repo } from '../shared/repo-types'
vi.mock('./effective-hook-config', () => ({
getEffectiveHooksFromConfig: (_repo: unknown, hooks: unknown) => hooks
}))
const REPO: Repo = { id: 'r', path: '/repo', displayName: 'r', badgeColor: '#000', addedAt: 0 }
/** Run a real archive script in a real shell, under a deadline short enough to test. */
async function runArchive(script: string, timeoutMs = 400) {
const { runHook } = await import('./hooks')
const dir = mkdtempSync(join(tmpdir(), 'orca-hook-deadline-'))
writeFileSync(join(dir, 'orca.yaml'), `scripts:\n archive: |\n ${script}\n`)
try {
return await runHook('archive', dir, REPO, dir, undefined, timeoutMs)
} finally {
rmSync(dir, { recursive: true, force: true })
}
}
// Why a real shell (#19334): this bug is invisible to a mock. Node's `exec({ timeout })` SIGTERMs
// the child and reports whatever it chose to do, so a hook that traps SIGTERM and exits 0 came
// back as a PASS — a hook cut off mid-archive, indistinguishable from one that finished its work.
describe.skipIf(process.platform === 'win32')('archive hook deadline', () => {
it('fails a hook that traps SIGTERM and exits zero, despite its zero exit', async () => {
const result = await runArchive("trap 'exit 0' TERM; sleep 30")
expect(result.success).toBe(false)
// Withheld, so the removal gate reads `unverifiable` rather than a pass.
expect(result.exitCode).toBeUndefined()
expect(result.output).toContain('timed out')
}, 20_000)
it('settles at the deadline even when the hook refuses to die', async () => {
const started = Date.now()
const result = await runArchive("trap '' TERM; sleep 30")
expect(result.success).toBe(false)
// A hook that ignores the signal must not hold a removal open until it finishes.
expect(Date.now() - started).toBeLessThan(10_000)
}, 20_000)
it('passes a hook that finishes inside its deadline', async () => {
await expect(runArchive('echo archived')).resolves.toMatchObject({ success: true })
})
it('reports an observed non-zero exit as the exit it is', async () => {
await expect(runArchive('exit 23')).resolves.toMatchObject({ success: false, exitCode: 23 })
})
})