Repository navigation
test(scratch): make the lifecycle oracle pass on Ubuntu 26.04 runners and fail instead of hang - #470
Merged
Merged
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…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>
tnunamak
force-pushed
the
fix/lifecycle-oracle-ubuntu-2604
branch
from
November 6, 2026 23:17
aca872e to
6cb08b3
Compare
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>
tnunamak
force-pushed
the
fix/lifecycle-oracle-ubuntu-2604
branch
from
November 7, 2026 06:06
f9770a5 to
fe1dcee
Compare
…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>
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The "Lifecycle owner oracle" CI step (the test file
scripts/test-scratch/run-command.test.ts, which checks howscripts/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'subuntu-latestrunner 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-sandboxis the vendored script that runs a command inside bubblewrap (bwrap, a Linux tool that starts a process in a restricted namespace). Whenbwrapworks, the command gets a private, empty/tmp, and the script binds back only the top-level/tmp/<name>directory that contains its--scratchroot (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 withmkdtemp(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,/tmpstayed 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-sandboxwas reparented to PID 1 andbwrap,node …/sandbox-child.ts,sh -c "sleep 300 …"andsleep 300were all still running; an outertimeout 90had 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.tsaddsLifecycleFixture, a per-test helper created withlifecycleFixture(t)(tis 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()andfixture.path()return paths only inside it and throw for anything else.fixture.spawn()starts a process. Its optionalhelperslist names files the command will use; if one is outside the runner temp,spawnthrows before launching. It also throws if the environment'sRUNNER_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 orsh -cscript bodies.afterhook (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 uniqueRUNNER_TEMPvalue. 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'ProcessTreeKilleraccepts. Where/procis unreadable it kills nothing and reports that through the test diagnostics.fixture.useRunnerTempForThisTest()points this process'sRUNNER_TEMPat the runner temp for tests that callrunScratchCommand(the wrapper's function form) in-process; calling it twice changes nothing, and teardown restores the previous value.run-command.test.tsgoes through the fixture; the per-testrmcalls and the hand-written group reaper are gone.timeout-minutes: 5limit 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
8374fd936bis 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.fixture.spawn, before any process starts. A helper made withfixture.pathis accepted, and a wrapper-launchedcatreads it.node --testrun (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./tmpvisibility probe, the test "a helper made with the fixture is readable through the sandboxed wrapper below /tmp", always runs withTEST_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_ACTIVEset) the probe is skipped with that reason rather than run without isolation; the lifecycle workflow, which runs outside any sandbox, is its gate.What I did not verify
/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.RUNNER_TEMPis not recognised as the fixture's and is not killed. Thetimeout-minutes: 5limit still applies.sh -cscripts or argument strings, so a script that builds a hidden/tmp/...path itself still fails by timeout, as before.mkdtempdirectly; they exercise scratch allocation and recovery functions in-process and start no command through the wrapper.Assisted-by: AI