Repository navigation
ci: refuse local preflight when measured sandbox setup is too slow for its deadlines #2370
Description
Context
On a WSL2 development host running Docker Desktop, every docker run --rm -v <source>:<target> leaves a permanent bind mount under /mnt/wsl/docker-desktop-bind-mounts/<distro>/<hash> in the distro's mount namespace. --rm does not remove it. Verified 2026-10-17: one docker run --rm -v … alpine true raised /proc/self/mountinfo from 7,962 to 7,963 entries. The repository's own Docker-based local proofs bind several sources per run, so the count grows with every local preflight: the pinned Playwright container, scripts/prove-worker-identity.ts and the native image proofs. On the observed host it reached 7,963 entries, 7,892 of them under that one directory. During one session it rose to 11,096.
Every bwrap sandbox copies the caller's whole mount tree, and --ro-bind / / re-binds and remounts each entry. The cost grows faster than the count. Measured by timing a nested bwrap --unshare-user --unshare-net --unshare-pid --ro-bind / / --tmpfs /tmp --proc /proc --dev /dev true inside a minimal root with N dummy bind mounts added:
| mounts | 18 | 2,012 | 4,012 | 8,012 | 16,000 | 24,000 |
|---|---|---|---|---|---|---|
| bwrap ms | 3 | 59 | 172 | 593 | 3,112 | 7,020 |
On the polluted host itself, whose mounts carry shared propagation, a trivial bwrap took 1.6–2.4 s.
Worker launch stacks several mount-namespace copies inside fixed 5 s budgets: (STATE_SETUP_MS, packages/ui-sdk/src/server/worker-launcher.ts:53) and (VERSION_PROBE_TIMEOUT_MS, packages/ui-sdk/src/server/version-probe.ts:9). Those budgets are correct product behaviour, but the host can no longer meet them. A/B on identical source and files, where B runs inside a bwrap that binds every top-level directory except /mnt (70 mounts):
packages/ui-sdk/tests/egress-worker-runtime.test.ts failing runs |
configured-wrapper test in packages/ui-server/tests/brain-client.test.ts failing runs |
|
|---|---|---|
| host, ~8,000 mounts | 10/10 (the worker's private turn-temp tmpfs was not mounted in time (ENOENT … /proc/<pid>/root/tmp), ~5.2 s) |
3/3 (whole test exceeds 30 s) |
| same host, 70 mounts | 0/10 | 0/3 |
Main's unmodified egress-worker-runtime.test.ts fails the same way on the host, so this is not specific to a branch. Adding dummy mounts in the clean namespace scales the wrapper test's duration: 8k mounts → 10 s, 12k → 17 s, 16k → 27 s, 20k → over 30 s, timed out.
Consequence: a local bun run check:pr on such a host fails, or worse produces timing-dependent receipts, for reasons unrelated to the change under test. Local reproduction evidence gathered on it is suspect. The current host prerequisites (requireRuntimeTools, scripts/check-pr.ts:52-72) check that bwrap exists and that a namespace can be created. They do not check whether creating one is affordable.
Related observations that may share this local trigger, none of them established: #2341 (configured-wrapper trace in broad feedback) and #2350 (/proc/<pid>/root/tmp/… ENOENT at mount inspection). Hosted runners have small mount tables and are not affected.
Scope
- In
requireRuntimeTools, on Linux and before any local check runs, time the actual sandbox setup the suite depends on. Run the existing namespace probe shape (bwrap --unshare-net --ro-bind / / --proc /proc --dev /dev /usr/bin/true) three times and take the fastest. Refuse when the fastest takes 1000 ms or more (maintainer ruling 2026-10-21; originally 250 ms). Healthy hosts measure about 3–25 ms. The observed failing host measured 1,600–2,400 ms; 5 s budgets break well before that. - The refusal reports the measured time,
bwrap --version, the/proc/self/mountinfoentry count and the largest mount-point prefix. It names both remedies:- Use bubblewrap 0.13 or newer, whose
mount_setattr()path removes the per-mount cost (ci: supply or require bubblewrap 0.13+ where sandboxes start, so leaked host mounts stop costing seconds #2375). - Restart Docker Desktop, or on WSL2 run
wsl --shutdown, to clear leaked bind mounts.
- Use bubblewrap 0.13 or newer, whose
- Document the prerequisite and its remedies in
docs/process/testing.md, next to the existing local runtime prerequisites.
Out of scope
- A fixed mount-count threshold. With bubblewrap 0.13.1, 8,000 mounts cost 16–23 ms, so a count rule would refuse hosts that are fine (see the dated correction below).
- Raising
STATE_SETUP_MS, the version-probe deadline or any test deadline. - Unmounting anything on the host from the preflight, or requiring root.
- Supplying a newer bubblewrap (ci: supply or require bubblewrap 0.13+ where sandboxes start, so leaked host mounts stop costing seconds #2375) or changing how the proofs pass sources to Docker (ci: bind only stable host paths in Docker-based local proofs, so each run stops leaking a mount #2376).
- Applying the same guard to bare
bun run test. That can be a follow-up once the check:pr form is settled.
Acceptance criteria
- On a host whose fastest probe takes 1000 ms or more,
bun run check:prexits nonzero before running any check. The message contains the measured time, the bubblewrap version, the mount count, the dominant prefix and both remedies. A host below the threshold proceeds unchanged. - Unit tests drive the decision through an injected probe timer and mountinfo reader:
- 999 ms passes and 1000 ms refuses.
- The fastest of three samples is the one compared.
- Dominant-prefix reporting is covered.
- Mutation receipt: removing the guard makes the refusing test fail on its named assertion.
- A native control on a real Linux host:
- With the system bubblewrap 0.9.x, the guard refuses in a mount namespace padded with enough dummy bind mounts that the fastest probe reaches 1000 ms, and passes unpadded. The 2026-10-18 receipt at 8,104 mounts measured 975.6 ms, which is below the ruled limit, so it needs more padding.
- With bubblewrap 0.13 or newer first on
PATH, the same padded namespace passes. - Record all three receipts in the PR.
docs/process/testing.mdnames the prerequisite and the remedies.
Verification
bun run check:pr --base origin/main, the focused unit tests, and the native padded/unpadded controls. The padding can use an unprivileged bwrap --unshare-user --cap-add ALL wrapper that bind-mounts empty directories and drops capabilities with setpriv before exec.
Contract impact
None. Local preflight tooling only.
2026-10-18 correction: switched the guard from a fixed 1,000-mount threshold to a measured sandbox-setup time. Measured in a minimal root, fastest of three, private or shared dummy mounts alike:
| mounts | bubblewrap 0.9.0 | bubblewrap 0.13.1 |
|---|---|---|
| 20 | 3 ms | 9 ms |
| 4,000 | ~170 ms | 9 ms |
| 8,000 | ~600 ms | 16–23 ms |
The cost is bubblewrap 0.9's per-submount read-only remount walk (containers/bubblewrap#754), which 0.13.0 replaced with mount_setattr(). So the mount count alone does not decide whether a host is healthy; the setup time does. Supplying a newer bubblewrap is #2375. Avoiding the leak itself is #2376.
2026-10-18 blocked handoff: PR #2372 now implements the revised timing-based scope, replacing its superseded fixed-count candidate. Latest source is preserved on branch as/work-issue-2370. Focused tests pass 16 / 76 assertions; prior-count and disabled-timing mutations both fail the named 250 ms refusal assertion and are restored. Native real-reader/probe controls: bubblewrap 0.9.0 passes with 104 mounts, refuses with 8,104 mounts at fastest-of-three 975.6 ms, and bubblewrap 0.13.1 passes the same padded namespace. No host mounts or product/test deadlines change. Updated local preflight passes 961 fast tests / 6,341 assertions and 42 affected tests / 271 assertions, plus all lint/metadata gates. Testing and CI policy docs agree with the new prerequisite.
The original fixed-count head's complete ready run 39074969929 failed three unit shards due to current-source fixtures assuming frozen producer hashes; the aggregate correctly fails too. #2373 has all five assertion receipts and the 19-pass base-caller control. No qualification hash or refusal guard is weakened. No complete hosted confirmation is claimed for the new timing revision. Resume after #2373: integrate its fixture correction, rerun required affected preflight, request complete ready-candidate confirmation, inspect actual current head/base/job checkouts and reviews, then verify retained 0.44.0 milestones and matching-head squash/main delivery. The PR remains draft under Refs, the issue remains open/blocked, and claim mount2370-20261018 is released after ownership readback.
2026-10-18 dependency reconciliation: #2373 delivered through #2388 on main; its canonical blocker is cleared. Resume the preserved timing implementation in #2372 with integrated main and fresh candidate proof. Earlier native/hosted receipts retain their actual identities.
2026-10-18 hosted-boundary correction: ready run 39087361437 at head 488e86f uses actual merge b370614 (base fcb7d5c; tree ab8c0419260e9866abb187ae155bb6dd3a1b3b2a). Browser jobs 117556834314/117556834338, layout 117556834370/117556834354, captures 117556834301 and endurance 117556834379 all fail before their proof containers: the new local sandbox timer was placed in the shared requireRuntimeTools helper, so these host runners now attempt unavailable bwrap. Actual checkout logs agree. This is a regression in this issue’s candidate, not a missing-tool waiver. The correction separates requireLocalSandbox at the executing local CLI entry from the existing hosted/shared runtime prerequisites. Native namespace checks stay intact; no runner image, isolation, assertion or timeout changes. A named hosted-container regression fails before correction; reintroducing the shared timer fails it again. Removing the local CLI guard fails the subprocess parent’s intended refusal assertion. Restored tests pass; focused/full CLI refuse setup before downstream proof and plan remains inspection-only. Original run remains failed/cancelled and incomplete after draft transition; verify/pack/runtime pass, units/native-host/aggregate are cancelled, and no combined green is claimed.
Delivered prerequisite: #2365 through PR #2374.
2026-10-18 independent proof prerequisite: corrected boundary preflight passes 961 fast and 44 affected tests / 283 assertions, strict types and actual unpadded local setup pass. The unchanged native qualification positive separately fails COMPLETE_NATIVE_QUALIFICATION_WITHOUT_DOCKER_OR_IMAGE_LOOKUP, expected 859 / received undefined (zero pass, one fail, two assertions). This is the existing #2365 frozen-positive/current-source defect. No qualification pin or refusal guard changes. Keep the corrected PR draft until that prerequisite is delivered; the maintainer explicitly leaves its current owner in charge.
2026-10-18 dependency refresh: #2375 is delivered by #2381 and its complete ordinary/padded/native-container compatibility receipts are recorded there. This preserved local branch integrates actual fetched main c4d31c6 (including separately delivered browser CI headroom #2415); refreshed local head 4d0ea77 has tree 2f07e7740ab969e1dcf56994fabc2a5983c42c53. The four-file timing prerequisite diff is preserved, with the timer confined to the executing local CLI. All 18 local-check tests pass / 88 assertions, including real CLI early refusal, plan-only behavior and hosted-container boundary. Strict types pass. #2365 remains open with its existing owner; no takeover, qualification pin refresh or new passing preflight/hosted claim is made.
2026-10-18 blocked handoff: refreshed head 4d0ea77 preserves the four-file timing prerequisite diff on main c4d31c6. All 18 prerequisite tests / 88 assertions and strict types pass. #2375 is delivered; #2365 remains open under its current owner. After delivery, integrate verified main and complete local/current-candidate hosted qualification before matching-head merge. Claim chain2370-20261018 is released after fresh uncontested ownership readback; draft #2372 preserves the implementation and earlier failing/mutation/native receipts.
Coordinated resumption — 2026-10-19
Claim release044-2370-r2-20261019, intended branch fix/2370-campaign-r2. The previous claim explicitly released custody; fresh readback confirms this issue is open and unassigned. The scoped #2365 correction is delivered by PR #2374, squash 90a95123cfceb169bd2ecf7c213544db742eaed7, whose parent/tree exactly match complete selected PR confirmation. Automatic main-push CI 39175300737 is still running and remains pending; no main-push success is claimed. Other named historical prerequisites are delivered.
Remove the satisfied dependency label and resume the preserved draft implementation, without reimplementing its completed work or relabelling historical runtime measurements. A dedicated GPT-6.1-Sol worker will review the diff and integrate verified main; installation and local/native execution require the parent's resource window. The parent serializes candidate confirmation and merges. Existing deadlines, assertions, images and qualification guards remain unchanged. Fresh candidate preflight and complete selected hosted proof remain required before delivery.
Current local qualification — 2026-10-19
Under parent-held claim release044-2370-r2-20261019, preserved draft #2372 is refreshed without rewriting source. Head 7322a0f9c3a561ad087f88f9c606b00dab2168f5, integrated/preflight base 90a95123cfceb169bd2ecf7c213544db742eaed7, tree 58935b5b1a2cca7d7f5ba4658e9aef45b1908ef8; diff remains four files, 137 insertions and 6 deletions. Actual plan exits 0. Normal default preflight exits 0 on its first execution in 113.73 seconds: 966 fast tests / 6,379 assertions and 44 import-graph feedback tests / 284 assertions, zero failures, with all lint/leakage/metadata/contribution gates passing. Frozen copyfile install used a fresh private cache; ownership, internal workspace links and single-link regular files were audited. Source/runtime inputs are unchanged from the preserved timing implementation; historical 18/88, native and mutation receipts retain their actual identities.
All hosted categories are selected; complete current candidate confirmation remains required and parent-owned. Keep #2372 draft under Refs, this issue open and milestone 0.44.0. No hosted confirmation, ready transition or merge is requested. #2376 remains independent. No product/test deadlines, images, assertions or qualification guards change. Earlier failures/cancellations are preserved as historical incomplete evidence.
Main verification regression — 2026-10-20
PR #2372 partially delivered the prerequisite as squash 9203e85966b409bfb4d645d48cc37344186d68e3 (tree 8792710ff4caeb48ee2afebc54d1602f3270c64d, parent 9a726820d52beb2e65c7c0c3da30312b7f676941). Automatic main run 39369165918 attempt 1, unit (4) job 118453057783 failed the whole 30-second test focused and full local CLI refuse setup before downstream proof while plan stays inspection-only. Both refusal modes completed six assertions; the final plan child returned 143 after timeout cleanup. The internal slow phase was not recorded. Whole-worker Git/planning inputs before the sandbox guard are a source-based hypothesis, not a measured cause.
Candidate confirmation 39367301854 passed the same source tree and worker partition; that does not qualify the failed main execution. Last green main did not contain this case. Earlier failed timeout-diagnostic run 39363255089 and the original failed/cancelled, native, mutation and qualification identity receipts retain their historical scope. Main is currently unqualified.
Under the existing parent-held claim release044-2370-r2-20261019, the follow-up scope is a bounded test fixture in tests/local-checks.test.ts: copy the unchanged actual CLI into a tiny owned Git fixture, forward only its static helper imports to the real implementations, and supply an explicit fixture base and controlled manifest change. Retain all three actual CLI modes, real planning, refusal and downstream tripwire assertions, deadlines and offline guards. Add fixture-root-before-discovery and actual-plan assertions plus a controlled mutation. No production behavior, timeout, qualification pin or image change. This bounded fixture cannot establish the historical slow phase or simulate that host.
The original acceptance criteria remain in force. Focused fixture/mutation proof, strict types and plan inspection are allocated locally; new candidate preflight, complete hosted confirmation and actual main verification remain outstanding and parent-coordinated. Preserve sole assignment, priority p2, milestone 0.44.0 and the existing campaign claim. This update neither closes the issue nor claims new readiness or green main.
2026-10-22: acceptance text updated to the 2026-10-21 ruling (1000 ms, delivered by #2472). The bubblewrap 0.9.x padded-refusal control now has to reach 1000 ms; its 8,104-mount receipt (975.6 ms) predates the ruling and does not show a refusal at the new limit. That receipt is the one criterion still open.
Activity
schlessera commentedon Oct 17, 2026
Claiming the explicitly requested #2370. Claim token: mount2370-20261018. Intended branch:
as/work-issue-2370, in the dedicated task checkout. I will add the Linux mount-count prerequisite, boundary/diagnostic tests, documentation and native padded/unpadded proof without changing runtime deadlines.[-]ci: refuse local preflight on a host whose mount table makes sandbox setup exceed its deadlines[/-][+]ci: refuse local preflight when measured sandbox setup is too slow for its deadlines[/+]schlessera commentedon Oct 17, 2026
Blocked handoff, updated scope: latest timing implementation replaces the original fixed-count candidate. Local
bun run check:pr --base origin/mainpasses 961 fast tests / 6,341 assertions and 42 affected tests / 271 assertions; a separate lint pass is clean. Focused file: 16 tests / 76 assertions. Failing-first and disabled-timing controls fail the named 250 ms refusal assertion; restored. Native 0.9.0 unpadded passes, padded 8,104-mount setup refuses at 975.6 ms, and 0.13.1 passes the same padding. These receipts describe the timing revision, not the older hosted candidate.Run 39074969929 is complete: 12 successful jobs, unit (1)/(2)/(3) plus proof fail. Unit (3) adds the already reproduced inclusion-fixture assertion; aggregate fails only because selected unit proof failed. #2373 records all five fixture assertions, actual immutable checkout identities and the 19-pass original-caller control. No new failure class, blind retry, hash refresh or waiver is selected.
This PR stays draft with Refs #2370. Resume after #2373 with integrated fixture correction, affected preflight and complete fresh candidate confirmation, actual job checkout/review inspection, verified 0.44.0 milestones and matching-head squash/main reconciliation. Claim mount2370-20261018 is released; implementation is preserved on this PR's branch.
schlessera commentedon Oct 18, 2026
Claim
chain2370-20261018, resuming preserved #2372 under the maintainer blocker-chain request. #2373 is delivered by #2388, squash fcb7d5c, with matching qualified tree and closed issue. Integrate that fixture correction, retain the measured timing implementation and its original native receipts, then obtain fresh local/current-candidate hosted proof before matching-head delivery.schlessera commentedon Oct 18, 2026
Blocked handoff for claim chain2370-20261018: draft #2372 preserves refreshed head 4d0ea77, main c4d31c6. All 18 prerequisite tests / 88 assertions and strict types pass; earlier timing/hosted-boundary mutations and native controls retain their identities. #2375 is delivered. #2365 remains open with its current owner, as the maintainer instructed. After delivery, integrate verified main and finish current preflight/selected hosted confirmation before matching-head merge and main reconciliation. Releasing only this invocation's uncontested assignment; preserved code and receipts remain in #2372.
schlessera commentedon Oct 18, 2026
Unblocked: #2365 is closed, so
scripts/sync-project.tsis removing theblockedlabel.schlessera commentedon Oct 18, 2026
Coordinated resumption — 2026-10-19
Claim
release044-2370-r2-20261019, intended branchfix/2370-campaign-r2. The previous claim explicitly released custody; fresh readback confirms this issue is open and unassigned. The scoped #2365 correction is delivered by PR #2374, squash90a95123cfceb169bd2ecf7c213544db742eaed7, whose parent/tree exactly match complete selected PR confirmation. Automatic main-push CI 39175300737 is still running and remains pending; no main-push success is claimed. Other named historical prerequisites are delivered.Remove the satisfied dependency label and resume the preserved draft implementation, without reimplementing its completed work or relabelling historical runtime measurements. A dedicated GPT-6.1-Sol worker will review the diff and integrate verified main; installation and local/native execution require the parent's resource window. The parent serializes candidate confirmation and merges. Existing deadlines, assertions, images and qualification guards remain unchanged. Fresh candidate preflight and complete selected hosted proof remain required before delivery.
schlessera commentedon Oct 21, 2026
Decision (maintainer, 2026-10-21): raise the local preflight sandbox setup limit from 250 ms to 1000 ms. A host with a Docker Desktop bind-mount backlog that cannot be cleared right away (about 3.7k entries, ~330 ms bare
bwrap) must still be able to run focused preflight. Setup at or above 1 s still refuses with the existing diagnostics. Hosted proof and all product and test deadlines are unchanged.Implemented in #2472, which also updates
docs/decisions/ci-utility.mdanddocs/process/testing.md. This issue's remaining scope is unaffected.chore(ci): allow local sandbox setup up to 1000 ms before refusing pr…
schlessera commentedon Oct 22, 2026
2026-10-22: updated the acceptance text from 250 ms to the 1000 ms ruled on 2026-10-21 and delivered by #2472. The boundary tests are now 999 ms permitted and 1000 ms refused.
Not closed. One criterion is not met at the ruled limit: the native control requiring that system bubblewrap 0.9.x refuses in a padded mount namespace. Its only receipt, from 2026-10-18, measured 975.6 ms (fastest of three) at 8,104 mounts. That is a refusal at 250 ms but a pass at 1000 ms, and #2472 added no native padded receipt. Everything else is delivered and green on main:
To finish, run one native control with enough padding that the fastest bubblewrap 0.9.x probe reaches 1000 ms, and show it refuses. Then show the unpadded run and bubblewrap 0.13+ on the same padded namespace both pass. Milestone 0.44.0 is kept.
schlessera commentedon Oct 22, 2026
Native receipt for the remaining criterion (rewritten 2026-10-22 for the ruled 1000 ms threshold): bubblewrap 0.9.x in a mount namespace padded until measured setup reaches at least 1000 ms makes local preflight refuse; the unpadded and bubblewrap 0.13 controls accept; no padding mounts leaked onto the host.
Full receipt
Native receipt for brain-kit #2370
Native run began at 2026-10-22 10:40:11 UTC for issue #2370, using Bun 1.4.2. The lock acquisition wait was 2714.402221 seconds; no probes ran during that wait.
The remaining native acceptance criterion is satisfied at the ruled 1000 ms limit.
All measurements use the delivered guard at commit
7b8a755ea329bd48a11b790e916c417b6f608eee(tree59135359a82265251a9ec14be65af3b993a07338), branchas/native-preflight-slow-setup-receipt, started from the identical fetchedorigin/mainSHA.The three delivered changes, #2372, #2451, and #2472, are ancestors.
scripts/check-pr.tsis byte-identical to its #2472 version.Native results
bun run check:prPadding added 12,000 empty-directory bind mounts, plus one private tmpfs containing them. Padding sizes measured: 12000. All recorded guard samples completed with native exit 0 under the existing 5000 ms probe budget.
The small observer in
guard-control.tsrecords the realBun.spawnSyncduration and forwards its actual result;requireLocalSandbox()uses its default timer and real/proc/self/mountinforeader. The guard's internal time includes the observer's small bookkeeping cost. The separate actual CLI run has no observer, preload, injected clock, or test double.Actual CLI refusal
Command executed under the lock in the padded namespace, using system bubblewrap first on PATH:
unshare --user --map-user=1000 --map-group=1000 --keep-caps \ setpriv --inh-caps=-all --ambient-caps=-all --bounding-set=-all \ "$bun_executable" run check:pr --base 7b8a755ea329bd48a11b790e916c417b6f608eeeExit code: 1. The CLI printed its check plan, then emitted this measured refusal before any
Checking ...downstream-check announcement:Mount isolation and cleanup
The entire native run, including the before/after count and cleanup, holds
flockon the sharednative.lockspecified in BRIEF.md.Before any mount, the padding script verifies that its mount namespace differs from the host, recursively makes propagation private, and rejects any remaining shared/master propagation fields.
Mounts are created only inside that disposable namespace. The same padded mount namespace
mnt:[4026532490]and count12098are retained for both bubblewrap versions; only the executable selected on PATH changes.Guard controls run as UID/GID 1000 in a nested user namespace with inherited, permitted, effective, bounding, and ambient capabilities all zero. The outer setup uses namespace-local root only to create padding. On process exit the namespace and its mounts disappear; the host-visible empty temporary directory is removed with
rmdir.Reproduction commands and inputs
For reproduction, use a fresh
receipt_dircontaining copies of the four scripts below. Setrepositoryto this checkout andwork_dirto the shared worker directory from BRIEF.md. These variables avoid embedding private checkout paths in public evidence.The driver acquires the lock before counting host mounts, then invokes:
The unchanged probe, three times for each guard decision:
Bubblewrap 0.13.1 was built privately from the upstream 0.13.1 archive, checked against the checksum in
docs/process/testing.md. Build prerequisites were downloaded and extracted into the receipt directory; the system bubblewrap and system packages were unchanged. Both executables have ordinary 0755 permissions.No repository files changed and no new PR, commit, source mutation, hosted CI run, ready transition, or merge was required. The accepting controls establish native guard acceptance; the actual CLI receipt establishes early refusal. Existing unit/mutation/hosted proof remains attributed to its original delivered PRs.
The coordinator owns posting this receipt and closing the issue. The artifact directory also retains raw stdout/stderr for every case,
summary.json, the complete locked-run log, and the build log.Complete external receipt scripts
These are local reproduction wrappers around the existing delivered measurement harness, outside the repository.
prepare-bwrap.sh
run-native.sh
pad-and-probe.py
guard-control.ts