Skip to content

test(scratch): make the lifecycle oracle pass on Ubuntu 26.04 runners and fail instead of hang - #470

Merged
tnunamak merged 15 commits into
mainfrom
fix/lifecycle-oracle-ubuntu-2604
Nov 7, 2026
Merged

tnunamak merged 15 commits into
mainfrom
fix/lifecycle-oracle-ubuntu-2604

Conversation

@tnunamak

@tnunamak tnunamak commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

The "Lifecycle owner oracle" CI step (the test file scripts/test-scratch/run-command.test.ts, which checks how scripts/test-scratch/run-command.ts, the wrapper that runs a test command inside a private scratch directory, starts, signals and cleans up commands) stopped finishing when GitHub's ubuntu-latest runner label moved to Ubuntu 26.04: two tests failed at their 20 s timeout, and then the step stayed open until someone cancelled the job. This PR fixes both tests and gives the test file a fixture that makes the two mistakes behind the hang fail fast and leave nothing running.

What was wrong

1. Two tests gave the sandboxed command a path it could not see.

scripts/test-sandbox/test-sandbox is the vendored script that runs a command inside bubblewrap (bwrap, a Linux tool that starts a process in a restricted namespace). When bwrap works, the command gets a private, empty /tmp, and the script binds back only the top-level /tmp/<name> directory that contains its --scratch root (the per-run directory the wrapper creates for the command) and the one that contains the working directory. The tests "a sidecar killed right after spawning the command…" and "owner SIGKILL then a command that leaves a background child…" (the owner is the wrapper process; the sidecar is the helper script that starts the command inside the sandbox) put the file their command writes (command.record, background.pid) in a second directory made with mkdtemp (Node's temporary-directory call) under /tmp. Inside the sandbox that directory does not exist, so the command could not create the file and the test timed out at 20 s while polling for it.

Only the new image exposed this. The 26.04 image ships bubblewrap 0.11.1 and it works for the runner user. The 24.04 image has no bwrap, so the sandbox fell back to weaker isolation, /tmp stayed shared and the helper directory was visible.

2. A failed test left a process group alive, and that kept the step open.

These two tests kill an owner or sidecar on purpose, so when an assertion fails nothing stops the command's process group. In the second test the wrapper is started with piped stdout and stderr, and the leftover command inherits those pipes. The test process then cannot exit, so Node's built-in test runner (node --test) never reports. On the runner after the failure, test-sandbox was reparented to PID 1 and bwrap, node …/sandbox-child.ts, sh -c "sleep 300 …" and sleep 300 were all still running; an outer timeout 90 had to kill the run (exit 124). In production CI, the step ran until the job was cancelled.

What this changes

  • scripts/test-scratch/fixtures/lifecycle-fixture.ts adds LifecycleFixture, a per-test helper created with lifecycleFixture(t) (t is Node's test context). It owns a unique runner temp directory: the directory the wrapper puts its scratch root in, and so the one directory tree the sandbox binds back. fixture.dir() and fixture.path() return paths only inside it and throw for anything else.
  • fixture.spawn() starts a process. Its optional helpers list names files the command will use; if one is outside the runner temp, spawn throws before launching. It also throws if the environment's RUNNER_TEMP (the variable that tells the wrapper where to put its scratch root) is not the fixture's. The check covers only the paths a test declares. It does not read argument strings or sh -c script bodies.
  • Teardown, in the test's after hook (the Node test-runner callback that runs once the test ends, pass or fail), closes the pipe ends the fixture opened and kills every process whose environment carries this fixture's unique RUNNER_TEMP value. Every descendant of a process the fixture launched inherits that value, including a command inside the sandbox (checked on this branch with the sandbox on and off). Teardown reads /proc/<pid>/environ, never kills its own process, and scans again because a dying parent may fork once more. It never signals a stored process or group id. It matches each process and signals it in one synchronous step, so a pid read earlier cannot be reassigned while other reads are pending. A small window remains between one read and its kill, the same one Jenkins' ProcessTreeKiller accepts. Where /proc is unreadable it kills nothing and reports that through the test diagnostics.
  • fixture.useRunnerTempForThisTest() points this process's RUNNER_TEMP at the runner temp for tests that call runScratchCommand (the wrapper's function form) in-process; calling it twice changes nothing, and teardown restores the previous value.
  • Every wrapper launch in run-command.test.ts goes through the fixture; the per-test rm calls and the hand-written group reaper are gone.
  • The timeout-minutes: 5 limit on the oracle step stays as a backstop for a hang with some other cause. The file runs in under a minute.

No product or sandbox code changed. The three test files total 2089 lines (2093 at the previous revision).

Evidence

  • Original failure: the hung run at 8374fd936b is https://github.com/PDP-Connect/pdpp/actions/runs/42271324505 (ubuntu-26.04, Node 24.22.2). The last green run before the image change is https://github.com/PDP-Connect/pdpp/actions/runs/40720715938 (ubuntu-24.04, e6099bd067). No lifecycle code, workflow or lockfile differed between them.
  • A helper outside the runner temp is rejected by a test through fixture.spawn, before any process starts. A helper made with fixture.path is accepted, and a wrapper-launched cat reads it.
  • Cleanup after a failed assertion is tested by a child node --test run (fixtures/leaked-group-after-failure.ts, run only by the parent test). It starts a wrapper whose command is a Node program that starts a Node keep-alive child. Each program writes its own pid to a file from inside the running process; the command writes its file only after the child's. The child run waits for both files (20 s deadline), requires both pids alive and the wrapper still running (otherwise it fails with "setup failed"), kills the wrapper's owner (the command and its keep-alive child still hold the wrapper's pipes), then fails an assertion. The parent requires exit code 1 within 60 s, requires the deliberate-failure message and no "setup failed" in the output (so a setup failure cannot stand in for it) and requires both pids to be gone, once with the sandbox enabled and once disabled. With the reaping line disabled, both runs fail, so the test does detect a leak. A keep-alive that exits before or right after it reports ready fails the run as a setup failure.
  • The /tmp visibility probe, the test "a helper made with the fixture is readable through the sandboxed wrapper below /tmp", always runs with TEST_SANDBOX_REQUIRE=1, so a host without usable bubblewrap fails it instead of running on the host /tmp. The workflow installs bubblewrap and checks it before the oracle step. Bubblewrap cannot nest, so inside the outer sandbox of the test-accounting run (TEST_SANDBOX_ACTIVE set) the probe is skipped with that reason rather than run without isolation; the lifecycle workflow, which runs outside any sandbox, is its gate.
  • A second test launches a sleeping process through the fixture and starts another that the fixture did not launch; teardown kills the first, destroys its pipe, removes the runner temp, and leaves the second running.
  • The oracle runs 59/59 and the canonical-entrypoint ratchet (it checks that package scripts and workflows start tests through the scratch wrapper) 11/11 locally on Linux with bubblewrap. CI results for the latest head are in the checks list below.

What I did not verify

  • Teardown relies on /proc. On a system without it the fixture kills nothing it did not start by handle and reports it; the leak protection does not apply there. The workflow runs on Ubuntu only.
  • A process that clears or replaces its own RUNNER_TEMP is not recognised as the fixture's and is not killed. The timeout-minutes: 5 limit still applies.
  • The path check does not look inside sh -c scripts or argument strings, so a script that builds a hidden /tmp/... path itself still fails by timeout, as before.
  • 28 other tests still create a directory with mkdtemp directly; they exercise scratch allocation and recovery functions in-process and start no command through the wrapper.
  • I did not run the suite on an ubuntu-24.04 image after the fixture change.

Assisted-by: AI

@vercel

vercel Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
pdpp Ready Ready Preview Nov 7, 2026 9:59am UTC

Request Review

…ds red

The two kill-on-purpose lifecycle tests started a command whose group
nothing stopped when an assertion failed. The command inherited the
wrapper's stdout and stderr pipes, so the test process never exited and
the step hung until the job was cancelled instead of failing.

On failure, stop every command group recorded under the test's scratch
parent and close the test's end of the wrapper pipes.

Assisted-by: AI
Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
On images that ship bubblewrap (Ubuntu 26.04 runners) the sandbox mounts a
private /tmp and binds back only the scratch parent's top directory and the
working directory. Two lifecycle tests put the files their command writes in
a second, sibling temp directory, so the command could not create them and
the tests waited out their 20 s read timeout. On ubuntu-24.04, bubblewrap is
absent, the sandbox adds no private /tmp, and the same tests passed.

Create that directory inside the test's own runner temp directory, which the
sandbox binds back.

Assisted-by: AI
Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
The oracle file normally finishes in under a minute. A leaked process
once kept it open until the 10-minute job limit was cancelled by hand.
Bound the step so a hang fails the step on its own.

Assisted-by: AI
Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
The two mistakes that hung the oracle on a bubblewrap runner could recur in
any test of this file: naming a helper path the sandbox's private /tmp hides,
and leaving a command's process group running after a failed assertion.

LifecycleFixture now owns the runner temp directory, rejects arguments and
variables that name a host temp path outside it, and in the test's after hook
kills the wrappers it started, closes their pipes and kills every process
group recorded under its scratch root. The per-test reaper and the manual
rm calls go away; a ratchet test fails if a test spawns the wrapper directly.

Assisted-by: AI
Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
A review of the lifecycle fixture found two gaps. The path guard rejected a
repository checked out under /tmp, although test-sandbox rebinds the top /tmp
directory of its working directory and the wrapper scripts are visible there.
And the spawn-before-running test starts a command whose marker records no
process group yet, so a failed assertion skipped its manual kill and left the
group alive.

The guard now also admits the working directory's top /tmp directory. The
fixture gains trackGroup(), which the test uses to register that group for
teardown.

Assisted-by: AI
Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
The lifecycle fixture guessed which strings in a command's arguments and
environment named a path the sandbox hides, and killed process groups by
numbers it had read from marker files or been handed. Both were wrong in
ways a reviewer could construct: a group id that died and was reused before
teardown could name an unrelated group, the check for the runner's own group
compared two pids instead of the group id, a regex ratchet missed
`spawn (process.execPath, ...)`, and the working-directory exception
resolved symlinks differently from the sandbox script.

The fixture now decides ownership by one fact it creates itself. `spawn`
requires `RUNNER_TEMP` to be the fixture's unique runner temp directory.
Every descendant, including a command inside the bubblewrap sandbox,
inherits that value. Teardown reads `/proc/<pid>/environ`, kills exactly the
processes that carry it (never itself), and scans again because a dying
parent may fork once more. It never signals a stored pid or group id. When
`/proc` cannot be read it kills nothing and says so through the test's
diagnostics.

The path guard shrinks to what a test states: `spawn` takes a `helpers` list
and throws before launching if a listed file is outside the runner temp.
`path` and `dir` already refuse anything outside it. The argument and
environment inference, the working-directory exception and the source-text
ratchet are removed. `adoptAsRunnerTemp` becomes the idempotent
`useRunnerTempForThisTest`, one restore instead of a stack.

Tests replace the ratchet with behaviour. An outside helper is rejected
through the public API. A process the fixture launched is killed while a
process it did not launch survives. A child `node --test` run kills a
wrapper owner while the command's group is alive and then fails an
assertion; the parent requires exit code 1 within 60 s and the group gone,
with the sandbox on and off. With reaping disabled, both child-run tests fail.

Assisted-by: AI
Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
…eak was real

Teardown now reads each process's environment and signals it in one
synchronous step, so no await separates the match from the kill. A pid
read earlier can no longer be reassigned while other reads are pending.
The window between one read and its kill remains; it is the same one
Jenkins' ProcessTreeKiller accepts.

The failed-assertion regression had a way to pass without its leak. The
shell now writes a readiness sentinel holding its own pid and its
background child's only after both exist. The test waits for it before
killing the owner, requires both pids to disappear, and requires the
deliberate-failure message so a setup failure cannot stand in for it.

Two tests drop their fallback finally blocks; the fixture owns failure
cleanup. The fixture header now says what is enforced (fixture-created
paths stay inside RUNNER_TEMP; declared outside helpers are rejected),
and the positive probe launches a real wrapper `cat` of a fixture file.

Assisted-by: AI
Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
Rulings asked for a kill path shorter than the async version and a one-line comment naming the remaining window. Fold the empty-scan exit into the loop condition, filter candidate pids up front, and drop the header sentence that restated the honest path claim. Behavior is unchanged.

Assisted-by: AI
Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
… failed leak setup

The positive helper probe ran host cat, not the sandboxed wrapper, so a broken
/tmp bind-back still passed. It now runs cat through run-command.ts with the
sandbox enabled and the fixture under /tmp.

The leak regression now requires both recorded pids alive and the wrapper still
running before its deliberate failure, with its own 20 s readiness deadline; the
parent test rejects any "setup failed" output.

The two fallback finally blocks keep only the conditional sidecar-group SIGKILL.

Assisted-by: AI
Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
…leak readiness

The /tmp visibility probe ran with TEST_SANDBOX_DISABLE=0, which still
allows the no-bubblewrap host fallback, so it could pass without any
isolation. It now sets TEST_SANDBOX_REQUIRE=1, and the lifecycle workflow
installs bubblewrap and proves it works before the oracle runs.

The leak regression no longer publishes a shell's background `$!` after a
200 ms settle. The command and its keep-alive child are Node programs that
write their own pid from inside the running process; the fixture waits for
both under the existing deadline and rejects a child that is gone.

Assisted-by: AI
Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
Bubblewrap cannot nest, so inside the outer sandbox of the test-accounting
run the probe's TEST_SANDBOX_REQUIRE=1 exits 3. The probe requires
bubblewrap only when no outer sandbox is active; the test-scratch-lifecycle
workflow runs the file outside any sandbox and gates it.

Assisted-by: AI
Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
Bubblewrap cannot nest inside the outer test sandbox, so the probe could not require it there and ran without isolation. A probe that runs unenforced and reports a pass is a false pass. Move the probe into its own test, always run it with TEST_SANDBOX_REQUIRE=1, and skip it with a reason when TEST_SANDBOX_ACTIVE is set. The lifecycle job runs it outside any sandbox and is its gate.

Assisted-by: AI
Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
…ecycle baseline

Inside the outer test sandbox the bind-back probe skips with a stated reason, because bubblewrap cannot nest there; the lifecycle job runs it outside any sandbox. Test accounting requires every skip to match the profile baseline exactly, so declare this one.

Assisted-by: AI
Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
…er than 5

On bash older than 5 (stock macOS ships 3.2) the script ran the command directly before it reached the TEST_SANDBOX_REQUIRE check, so a run that required isolation got none and could pass. Check the requirement first on that path and exit 3, as the other unsupported paths do. TEST_SANDBOX_DISABLE=1 still runs directly.

Assisted-by: AI
Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
The vendored executable now carries the upstream fix that makes TEST_SANDBOX_REQUIRE=1 exit 3 on bash older than 5. Update its recorded sha256 and name that upstream commit in the provenance note.

Assisted-by: AI
Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
@tnunamak
tnunamak merged commit c9b8d93 into main Nov 7, 2026
7 checks passed
@tnunamak
tnunamak deleted the fix/lifecycle-oracle-ubuntu-2604 branch November 7, 2026 10:14

This branch was successfully deployed

1 active deployment
Preview — 01cb04f2 Deployed Nov 7, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant