Skip to content

feat(ci): Add revdep4, sequential halves driven by a two-ended bash work queue - #2857

Open
krlmlr wants to merge 37 commits into
mainfrom
claude/revdep-check-collision-cfavj8-seq
Open

feat(ci): Add revdep4, sequential halves driven by a two-ended bash work queue#2857
krlmlr wants to merge 37 commits into
mainfrom
claude/revdep-check-collision-cfavj8-seq

Conversation

@krlmlr

@krlmlr krlmlr commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Proposes revdep4, the queue engine: old and new halves of each reverse dependency checked sequentially, with the lost concurrency won back across packages by a custom bash work queue — one of two sibling proposals (the other is revdep3, #2856). Both reuse the good bits of revdep2 through a shared, engine-agnostic core, and are fully interoperable with each other.

The problem

revdep2 checks each reverse dependency against the released and the dev igraph simultaneously on one host. The PSOCK port collision (patched with R_PARALLEL_PORT) was one member of an open-ended interference class — shared /tmp, caches, locks, any singleton a check assumes it owns. Simultaneously checking the same package with two libraries is not a supported mode of operation.

What revdep4 does

Removes the simultaneity instead of isolating it: per package, the old half runs, then the new half — no overlap, ever. Parallelism moves to the package level: a shard runs workers packages at once (default 4, the runner's cores), each check in its own container, so different packages cannot collide either (stronger than revdep2, which only separated the two halves' ports).

The queue (revdep4/queue.sh, bash, flock-guarded cursors) consumes the shard's heaviest-first package list from both ends: one heavy lane takes the heaviest remaining package — the longest checks start first and are never the straggler discovered last — while the remaining light lanes drain the long tail of cheap packages from the other end; the two meet in the middle. Deadline-aware claiming defers what no longer fits; a crashed claim still writes an error manifest line through a four-rung fallback ladder (compare-one → --error rerun → printf-JSON template → supervisor sweep), so no claimed package can ever vanish silently; three check slices with an upload after each bound what a reclaimed runner can lose.

Heaviness — answering the design question directly: the queue key is expected check seconds — measured on this infrastructure by earlier revdepx runs (either workflow, youngest wins), else CRAN's T_total × a self-calibrating scale, else the cohort median. Package size and dependency count/size are deliberately not in the key: they price installation, which the shared universe image amortizes to zero (dependency count still gates the depfail screen). This is revdep2's proven cost model, re-priced for sequential halves (×2 per package, ÷workers per shard, with a max(heaviest, sum/workers) bound so a giant-dominated shard is not flattered). In the live runs the model self-corrected on its first opportunity: the second full run consumed the first's measured timings and cut 20 shards in one wave instead of 40 in two.

Sequential halves make per-half timings real: t_old/t_new become true measurements (revdep2 could only record the pair's wall clock for both), which sharpens the cost model over time.

The old half always runs; a stored old result is a second opinion only. Where the plan certifies an earlier run's old-version result as comparable — same revdep version, our CRAN version, container R, base-image tag, dependency fingerprint, within age — the fresh old check is compared against it and baseline_agrees records whether they match, with disagreements reported as drift. It never substitutes for the check: a fresh old is the only result whose provenance the run fully controls. (Baselines from a different platform — another R version, another base image — are never offered; the key walls them off.)

Pipeline (shared with revdep3): plan + base image (parallel) → build (dev binary inside the container) → universe image (whole dependency universe + sysreqs, delta-updated, age-bounded) → test shards (queue) → collect. Containers also bring per-check memory caps (default 6g; an OOM kills one container, not the runner — observed working live), low-memory check compiles (-g0, MAKEFLAGS=-j1 inside the container; both halves identical, CRAN flags restorable via REVDEPX_CHECK_FLAGS), and a pinned check platform (r-version, default 4.6.1 — at least one reverse dependency needs R-release) with qpdf/ghostscript/pandoc/TeX/Xvfb, the tcl/tk runtime (tcltk is a base R package, invisible to pak's sysreqs machinery) and Rust (six revdeps compile cargo crates). Dependency metadata spans CRAN and Bioconductor — the checked packages are CRAN reverse dependencies, but what they depend on may live in either repository.

Kept from revdep2 / dropped

Kept (via .github/workflows/revdepx/): the planner with all its inputs (packages/broken/retry-run/part/dry-run), the manifest schema and result vocabulary, baseline lineage, timings + calibration, three-slice uploads, resource sampler, chunked deadline-bounded installs, load test, comparison/diff/salvage, report/collect and the committed revdep/ record. Dropped: the port hack, the preflight job and all prebuilt-library artifacts/donor walks/tar machinery, host toolchain setup on shards, per-shard host installs (kept only as disaster-recovery fallback when the universe image is unavailable — a fallback the live runs exercised end-to-end, repeatedly). revdep2 itself is untouched by this PR.

Compatibility with revdep3

Both PRs ship .github/workflows/revdepx/ byte-for-byte identical (this PR adds revdep4.yaml + revdep4/; #2856 adds revdep3.yaml + revdep3/; merging both leaves one copy, no conflicts). One artifact family (revdepx-*), one manifest/baseline/timings schema, one comparison code path, one universe-image lineage, one concurrency group per ref. Either workflow reads the other's baselines as second opinions, consumes the other's timings, retries the other's runs, and starts from whichever universe image was refreshed last.

Live-test results

The full validation run (32158907637, most, depth 2, R 4.6.1) completed green end to end: 3435 reverse dependencies, 20 shards in one wave, ~6.5 h wall clock — 3231 ok, 21 newly broken, 49 fail to install, 121 with uninstallable dependencies, 6 new-half timeouts. revdep3's cross-run of the same parameters (32196879628) reproduced all 21 newly-broken on the other engine and resolved the residual buckets: five of the six "timeouts" were queue-worker core contention at the 1200 s floor, not dev slowdowns; the dmesg watch caught the kernel OOM-killing cc1plus at the derived ~3.4g per-check cap while the report said only "fails to install"; and the 121 uninstallable were all Bioconductor, dropped by the planner's CRAN-only metadata before pak (whose pinned repositories included Bioconductor all along) ever saw them.

Follow-up commits closed each cause: dependency metadata now merges the Bioconductor repositories (install union 4406 → 4675), the per-check cap defaults to 6g, check compiles run with -g0/-j1, failures.md carries the real tail of 00install.out (a container-path mismatch had left it "<00install.out file does not exist>"), and a detected compiler kill is named in the manifest message.

A targeted rerun of all 204 not-ok packages (32260705703, packages: broken) validated the lot in one pass: 171 of 204 ok — all 121 uninstallable-dependency packages resolved, 42 of 49 install failures installed (blavaan, gllvm and multinma among them, confirming the memory diagnosis), all 21 newly-broken reproduced. The universe job took the delta path for the first time (145 new packages on top of the published :latest, pushed in 50 min), and the new install-log capture identified the entire remaining residue at a glance: all six failures were sh: 1: rustc: not found — one missing toolchain, not memory.

A final rerun on the Rust-enabled base (32281237129) closed the loop: all six Rust packages now install and check ok, and the committed record survived a subset run intact at 3435 rows — validating the collector fix this rerun's predecessor exposed (a subset run used to replace the committed manifest with its own rows; rows outside a run's plan are now carried). It also surfaced one last sysreqs blind spot, fixed here: tcltk is a base R package, invisible to pak's sysreqs, so a universe built for a small package set shipped no libtcl and the three tcltk-using packages failed to install — the base image now carries the tcl/tk runtime it already had Xvfb for. Current committed record: 3410 ok · 20 newly broken · 3 failed (the tcltk trio, cured by the next run's base) · 1 timeout (ctmm) · 1 deferred. Two earlier newly-broken (c3net, ggm) passed on re-check — flaky rather than broken.

The stable newly-broken set (reproduced on both engines and on re-check): cfid, comato, cranly, dci, degreenet, ECoL, ggraph, glyrepr, GoodFitSBM, manynet, MetaNet, migraph, nat, netrics, R6causal, scistreer, SEMgraph, sfclust, tidygraph, vkR.

Notes for review

  • Nine push-triggered live runs drove out every infrastructure bug on this branch, each fixed here: docker commit filling the runner's single disk killed two runners with their logs (now: a measured guard, a docker-stop watchdog, and a pre-commit evidence artifact uploaded before the copy starts — a dead runner loses even its streamed log lines); file.rename() across bind mounts silently dropped the dev binary from its artifact (EXDEV; now a checked copy); a 755 /tmp bind mount broke every apt call via apt-key's temp files (now 1777, reproduced and verified locally); each check slice truncated the shared manifest (file.create() truncates) and destroyed two thirds of a run's finished results (now create-if-absent; proven fixed by the final run's 4 deferred vs 2293 before); the runners' containerd image store made committed images unpushable OCI indexes and docker save a 1.3 kB shell (now: the classic graphdriver store via the daemon.json the workflow already writes, plus a 100 MB save floor and shard-prep falling through to a local build on any bad image); pak::pkg_sysreqs() over all 3435 packages was OOM-killed at 14 GB (now chunked, per-chunk fault-tolerant, with memory caps on build containers); Tk-based packages died installing headless (now Xvfb in the base image, used for installs, load tests and checks); and the one-at-a-time install salvage became three-way divide-and-conquer (5–29× fewer pak calls in simulation).
  • The universe image could not publish under the containerd store, so three full runs ran entirely on the shard-local fallback (~35 min extra per shard) — the degraded path is production-proven. The classic-store fix is proven too: revdep3's validation run published the universe image to GHCR, all 40 of its shards pulled it in minutes, and the broken-packages rerun rebuilt it as a 50-min delta on top.
  • The temporary push triggers that drove the live runs are reverted: both workflows are dispatch-only again with strong/depth-1 defaults restored and the check platform pinned at R 4.6.1.
  • First push after merge creates the GHCR packages; org policy forbidding GITHUB_TOKEN package writes degrades gracefully to artifact transport, and a missing image to shard-local builds.

  • By submitting this pull request, I assign the copyright of my contribution to The igraph development team.

…ork queue

revdep2 checks each reverse dependency's CRAN and dev halves
as two simultaneous `R CMD check` processes on one host.
Simultaneously checking the same package against two libraries
is not a supported mode of operation for the packages being checked:
the PSOCK port collision that needed the `R_PARALLEL_PORT` split
was one failure class of an open-ended family.

revdep4 removes the simultaneity instead of isolating it:
per package, the old half runs, then the new half, never both at once.
The lost concurrency is won back across packages --
a custom bash work queue (revdep4/queue.sh) checks
REVDEPX_WORKERS packages at a time, each half in its own container,
which isolates different packages from each other too.
The queue consumes the shard's heaviest-first list from both ends:
one worker takes from the heavy end,
so the longest checks start first
and are never the straggler discovered last,
while the rest drain the cheap tail from the other end.

Sequential halves make per-half durations real measurements,
and give baseline reuse teeth again:
a valid old-version result from an earlier run of either workflow
skips the old half outright --
sound now, unlike revdep2's abandoned cross-run comparison,
because both eras check inside the same pinned universe image
and are parsed by the same code.

The engine-agnostic core lands in .github/workflows/revdepx/,
shared byte-for-byte with the sibling revdep3 proposal:
one artifact family, one manifest, baseline and timings schema,
one comparison code path, one universe-image lineage --
either workflow reuses the other's baselines, timings,
images and reports, and `retry-run` accepts a run of either.

revdep2 stays untouched;
this is a proposal beside it, not a replacement of it yet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
claude and others added 28 commits August 17, 2026 15:53
…nion only

The queue engine briefly revived what revdep2 had retired:
substituting a stored old-version result for the old check
when the plan judged it comparable.
The container platform makes that far safer than it was,
but a fresh old check is the only result
whose provenance the run fully controls --
so the old half now always runs, in both engines,
and a comparable stored result rides along
purely as a second opinion:
`baseline_agrees` records whether the fresh check reproduced it,
and a disagreement is printed as drift.

Concretely: the queue engine's per-package price is always two halves
(no baseline discount in the plan),
the queue file loses its skip_old column,
queue.sh always runs both halves,
compare-one.R always reads a fresh old side,
and compare_halves() loses its baseline-substitution mode.
The baseline artifact, its validity conditions
and the cross-workflow lineage are unchanged --
only what a valid row is *for* has narrowed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
…licy

Two sentences still described the retired skip-old design:
the timings bullet halved the queue plan's bill
where a baseline covered the old half,
and the baseline bullet spoke of reusing rows.
Both halves always run fresh now,
and a stored row is only ever a second opinion.

(This push also re-triggers the checks
that failed during the GitHub outage.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
An independent review of both revdepx branches confirmed four bugs
and a handful of gaps; this applies everything that lives in the
shared core or on this branch.

- The check-slice cut no longer dies on shards with fewer runnable
  packages than slices: `seq(index, n, by = of)` is an R error when
  index exceeds n, which crashed slices of 1-package retry shards and
  -- with an empty runnable set -- erased recorded depfail diagnoses
  into `missing`. The slice is also cut before the source downloads
  now, so each slice fetches only its own tarballs instead of the
  whole shard's three times over. The same seq() trap is fixed in
  plan.R's `part` split.
- The base image now installs callr: util.R's run_with_timeout()
  silently degrades to an unbounded inline call without it, and every
  "bounded" pak call of the in-container universe build ran with no
  clock -- the exact hang class revdep2 added callr for on the host.
  A denied base-image push is now a hard error too: unlike the
  universe image it has no artifact fallback, and everything
  downstream pulls it.
- timing.json's `script_seconds` accumulates across check slices;
  before, the final slice's overwrite dropped the earlier slices'
  driver time and the calibration charged it to per-shard setup,
  inflating every later plan.
- The `compared` commit gate counts only this run's own comparisons:
  carried-over retry results could pass it and let a run that learnt
  nothing overwrite the committed report.
- ensure_check_sysreqs() runs `apt-get update` before its direct
  install -- the base image deletes the apt lists, so the install
  otherwise failed quietly.
- The shared concurrency group is built from `github.ref_name`, so a
  dispatch with the `ref` input filled in serializes against one
  without it on the same branch.
- check-half.sh tells its outer safety-net timeout apart from a real
  check timeout (both exit 124) by when the axe fell, and reports the
  outer case as a runner failure, not a package one.
- The queue's claimed.log and queue-state.json are copied into the
  results artifact per slice, and queue.sh refuses loudly to run
  without flock instead of racing its claim cursors in silence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
The previous run passed every substantive step --
build, check, pkgdown -- and failed only on its final
`gh api` call posting the success status,
which caught a stray HTTP 503 from the outage's tail.
The sibling branch's identical run posted its status
a minute later and went green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
…h 2)

A workflow_dispatch workflow is only registered once its file exists
on the default branch, so a branch-only workflow cannot be dispatched
by API or UI. Fire this first run from the branch itself instead --
the same device the old revdep.yaml uses -- with `which: most` and
`depth: 2` hardcoded, since a push event carries no inputs.

Revert after the run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
The first live run failed in the build container:
"vignette builder 'knitr' not found".
The pak bootstrap installs the package's hard dependencies,
and building vignettes needs the Suggests tree on top --
which revdep2's host build inherited from setup-r-dependencies
without anyone deciding it.

The binary exists to be installed into the checks' new-half
library, and no check ever builds or reads the package-under-test's
own vignettes, so `--no-build-vignettes` removes the requirement
instead of importing it.
Also forward GITHUB_SHA into the container,
so meta.json records the real commit
when git cannot answer inside the mount.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
The first live run's universe job died in `docker commit`:
these runners have one disk (there is no /mnt volume),
`docker commit` writes a full copy of the container's rw layer --
the entire installed universe -- onto it,
and when the copy filled the disk the runner agent itself
could no longer write.
The job was killed with its logs, artifacts and cache saves;
the step froze as `in_progress` and the `always()` steps never ran.

So: measure the rw layer (`du` over the overlay upper dir)
and the free space first,
and skip the commit with a loud warning when it cannot fit --
the shards' shard-local build fallback carries the run,
which is the same path a failed universe job already takes,
and which the dying run demonstrated works.
After a successful commit,
drop the build container before the push,
so the original copy of the delta is freed
before the push wants scratch space.
The artifact fallback gets its own space check,
and a new `saved` output gates the artifact upload and download
instead of overloading `pushed`.

The resource sampler turned out to stream nothing after its own step
ended: a backgrounded sampler keeps appending to its RESOURCE_LOG file,
but its stdout stops reaching the job log --
and the streamed lines are all that survives a dying runner,
which is exactly when they are needed.
The long steps (universe build, commit, shard image prep)
now run their own in-step samplers on top of the file-writing one.

Also rename the "Move docker onto the big disk" steps to tell the
truth -- there is no big disk to move to, only room to make --
revert the temporary push trigger and its hardcoded inputs
now that the live run is under way
(removing the trigger in the same push means this push starts no run),
and bump docker/login-action to v4 for the Node.js 24 runtime.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
Every shard of the first live run failed installing the dev binary:
the revdepx-pkg artifact carried meta.json and the source tarball,
but not the binary meta.json named.
build.R moved the binary with file.rename(),
and in the build container the working directory and OUT_DIR are
two different bind mounts --
rename(2) across mounts fails with EXDEV,
which file.rename() reports only as a FALSE nobody read.
Copy with file.copy(), check the result, and fail loudly,
for the source tarball too.

Also chown the pak cache back to the runner user
after the build and universe containers wrote it as root:
their 600 lock files made the cache post-step's tar fail
("Permission denied"), so the cache never saved --
and a warm cache is exactly what makes a retry cheap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
…th 2)

The first live run died on the two bugs the previous commits fix
(the universe commit filling the one disk; the dev binary lost to a
cross-mount file.rename()), so the test run runs again.
Same device as before -- push trigger plus hardcoded inputs,
because a workflow_dispatch workflow is only registered once its file
exists on the default branch -- and this time `run-name` is hardcoded
too, so the run is titled what it actually checks.
Revert this commit after the test run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
…dence first

The guarded commit died again -- run 32084560474, ~34 minutes in,
same as the run before it, runner and all --
and a dead runner turns out to lose even the log lines it already
streamed, so the guard's own measurements died with it.

Three consequences, all in the universe job:

- A new step measures the delta (bytes AND inodes -- `df` can show
  free gigabytes while millions of small package files run the inode
  table dry) and uploads the numbers as an artifact BEFORE the commit
  runs. Whatever kills the copy, the post-mortem now starts with data.
- The guard reads those numbers, refuses an unmeasured delta
  (after two dead runners the burden of proof sits on the commit),
  and checks inodes as well as bytes.
- The commit runs under a watchdog that stops the docker daemon when
  disk, inodes or memory approach zero -- killing the client would
  not abort the daemon-side copy -- then restarts it so the report,
  artifact, cache save and logout still work. A wrong measurement can
  cost the commit, never a third runner.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
…ost, depth 2)"

This reverts commit 0a2c237.

The live test run (32084560474) is under way pinned to its own
commit, so the branch no longer needs the push trigger --
and reverting it in the same push as the universe hardening means
that push starts no spurious sibling run.
The workflow is dispatch-only again, with the run title and the
which/depth defaults back on the dispatch expressions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
… before retrying singly

Two answers to what the universe build log shows.

The one-at-a-time installs at the end are the salvage pass:
a pak transaction is all-or-nothing,
so one broken package strands its whole 400-package chunk,
and the retry then paid pak's per-call resolution overhead
once per stranded innocent --
hundreds of calls, hours of tail,
and the install deadline cutting off packages
that were never broken at all.
A middle rung now re-tries the missing set in chunks of 50 first;
only what still refuses gets the one-at-a-time treatment
that names each failure on its own.

And the rw layer was carrying temporary files into `docker commit`:
every pak install that hit its timeout died mid-build
and left its extracted sources and objects in the container's /tmp,
and apt's package lists stayed behind from the sysreqs runs --
all of it copied by the commit,
and on the delta path inherited by every descendant image.
The build containers (universe job and shard-local fallback alike)
now bind-mount /tmp from the host,
so build residue never enters the layer at all,
and image.R sweeps apt lists and any remaining /tmp leftovers
before the library is indexed.
The pak download cache was already a host bind mount.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
…a flat scan

The one-at-a-time salvage pass paid pak's per-call overhead once per
stranded innocent -- and that overhead is irreducible from the
driver's side: the resolver runs per call, in pak's own private
subprocess, so the only lever is the number of calls.

Divide and conquer, in the driving R process (which risks nothing:
every pak call already runs in its own clocked subprocess).
Retry the missing set whole; a failing set of more than one package
splits into three, down to single-package leaves where a genuine
failure names itself. Subsets without a culprit succeed as one call,
so d culprits hiding in n packages cost about 3 * d * log3(n) calls
instead of n. Simulated with pak's prefix-install behavior modelled:
for n = 400 and d = 1..10, trisection takes 14-78 calls where the
flat scan took 400; fan-outs 3, 4 and 5 are within noise of each
other (k/ln k is minimal at 3), and 2 is ~15% worse.
This supersedes the chunks-of-50 middle rung from the previous
commit; missing_from() re-measures before every call, so nothing a
failing transaction did install is ever asked for twice, and a big
retry that merely times out splits and continues instead of
starting over.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
…th 2)

The first live run died on the two bugs the previous commits fix
(the universe commit filling the one disk; the dev binary lost to a
cross-mount file.rename()), so the test run runs again.
Same device as before -- push trigger plus hardcoded inputs,
because a workflow_dispatch workflow is only registered once its file
exists on the default branch -- and this time `run-name` is hardcoded
too, so the run is titled what it actually checks.
Revert this commit after the test run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
Run 32106802120 showed every apt-get that pak executed failing.
Not privileges -- everything that runs apt runs as root -- but the
/tmp bind mount the previous hygiene commit introduced: a plain
mkdir hands the container a 755 directory where /tmp semantics
demand 1777, apt-key cannot create its temporary config there
("Couldn't create temporary file /tmp/apt.conf.XXXX"), and from
that moment every repository fails signature verification and every
apt-get fails with it.

Reproduced locally against rocker/r-ver:4.5.3:
a 755 host directory mounted at /tmp fails apt-get update on all
four repositories exactly as the run did;
chmod 1777 on the same mount and the same update runs clean.

chmod 1777 on every host directory that becomes a container's /tmp:
the universe build, the shard-local fallback build, and -- for
strict /tmp semantics even single-user -- the check containers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
…hardening

Four changes, each answering something the live runs surfaced.

R 4.6.1, hard-coded: at least one reverse dependency needs R-release,
so the pinned default moves from oldrel to 4.6.1
(dispatch can still pass 'oldrel', 'release' or any version).
The base image tag follows the recipe hash, so the new platform
builds itself on the next run.

Xvfb in the base image, used everywhere R runs: Tk-based packages
(gWidgets2tcltk) initialise Tk while lazy-loading AT INSTALL TIME
and die headless with `[tcl] invalid command name "font"`.
CRAN's own machines check under X; ours now start a virtual
framebuffer -- image.R for installs and load tests, check-half.sh
for the checks themselves.

The universe delta is measured even under the containerd image
store: GraphDriver is empty there ("upper: <none>"), which made the
guard skip a commit that would have fit five times over
(run 32114635495: ~10G delta, 94G free).
`docker ps -as` prices the rw layer instead when du cannot.

And the sharp edges from the same run: the check-sysreqs survey runs
pak::pkg_sysreqs in chunks of 300 (one call over 3435 packages grew
past 14 GB and was OOM-killed), the build containers get memory caps
so a future kill stays inside the container instead of gambling the
runner, the load test computes its roots from Depends+Imports only
(LinkingTo-only packages -- BH, cpp11 -- were counted as covered yet
never actually loaded; now they are roots and load-tested),
and the check slices run in-step resource samplers,
so the minute-by-minute load lines reach the log
from the phase where the minutes actually go.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
…ost, depth 2)"

This reverts commit bf9ac26e2517889113c98d270ff7fa15b40a54cd.

The live test has its completed run (32114635495: 3435 packages,
20 shards, collect green, report committed), so the branch goes back
to dispatch-only. Removing the trigger in its own push means this
push starts no run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
Run 32114635495's manifest recorded 2293 of 3435 packages as
`deferred` with empty messages -- yet every shard's three check
slices each ran for a real hour. The slices did the work;
the bookkeeping destroyed it: the check phase opened with
`file.create(manifest_path)`, and file.create() TRUNCATES an
existing file. Every slice wiped its predecessors' manifest lines
at startup, the account-for-everything sweep then faithfully
re-wrote those packages as `deferred`, and the per-slice artifact
upload (same name, overwrite) propagated the loss. The sweep at the
end was already written to leave existing lines alone --
it just never got to see them.

Create the manifest only when it does not exist yet.
The interim semantics stay exactly as designed: earlier slices'
lines survive, later lines win per package in the collector,
and the last upload carries the whole shard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
The reports committed by runs 32084560474 (cancelled mid-run) and
32114635495 (the slice-truncation bug, fixed in the previous commit)
carried entries that describe harness artifacts as package results:
shard-cancellation "deadline" failures for packages that were never
reached, and `deferred` rows for 2293 checks whose finished results
the truncation destroyed. Rather than hand-prune fabrications,
the report returns to the last trustworthy state (main's), and the
next complete run rewrites it from scratch with real data.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
…th 2)

The first live run died on the two bugs the previous commits fix
(the universe commit filling the one disk; the dev binary lost to a
cross-mount file.rename()), so the test run runs again.
Same device as before -- push trigger plus hardcoded inputs,
because a workflow_dispatch workflow is only registered once its file
exists on the default branch -- and this time `run-name` is hardcoded
too, so the run is titled what it actually checks.
Revert this commit after the test run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
Run 32148999976 got everything right up to the handoff:
delta measured via docker ps -as (14.9G -- the SizeRw fallback
works), guarded commit succeeded in ten minutes, runner healthy.
Then three dominoes:

- `docker push` refused the commit-created image outright:
  the containerd store commits an OCI manifest INDEX, and pushing
  one is rejected ("trying to push a manifest list/index").
  Push `--platform linux/amd64` -- the fix the error message itself
  prescribes -- with the plain push kept as fallback for daemons
  without the flag.
- The artifact fallback then `docker save`d a 1336-BYTE
  manifest-only shell of the same index -- no layers -- and shipped
  it. Save with `--platform` too, and refuse to upload anything
  under 100 MB: a universe image compresses to gigabytes, and an
  empty shell costs every shard its run.
- shard-prep docker-loaded the shell, correctly recognised it
  ("no readable /opt/revdepx/lib-index.json") -- and exited instead
  of falling back. A bad image from the pull or the artifact now
  falls through to the shard-local build; only the locally built
  image still fails the shard on that test, because there is no
  further rung.

Also: one failing chunk of the sysreqs survey no longer costs the
whole survey (per-chunk tolerance; the same run lost it to a single
pak subprocess error).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
Run 32158907637 characterised the containerd image store's commit
completely: the committed image is an OCI index whose children the
daemon never pulled, so a plain push is refused ("not all of them
are available locally"), `--platform linux/amd64` cannot select a
platform the commit never labelled ("does not provide the specified
platform"), and a plain save reduces to a manifest-only shell --
which the new 100 MB floor correctly refused, and the shards fell
through to local builds as designed.

The daemon.json this workflow already writes now also disables the
containerd snapshotter, restoring the classic store whose commit is
a plain single-manifest image that pushes, saves and du-measures
exactly as everything here assumes. The --platform fallbacks and
the save floor stay as belt and braces.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
…ost, depth 2)"

This reverts commit db4fde9.

Run 32158907637 (most, depth 2, R 4.6.1) is under way pinned to its
own commit and needs no trigger; reverting it here lets the held
classic-store fix ride the same push without spawning a sibling run.
Re-add the trigger only if another push-driven revdep4 test run is
wanted before the PR merges.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
…ap checks at 6g

Three findings from the most/depth-2 live runs, one commit each way
they bit:

Bioconductor: 121 packages came back depmissing on dependencies
(DESeq2, pwalign, ...) that pak would have installed happily --
pinned_repos() has carried the Bioconductor repositories all along --
because install_closure() intersected every dependency list with CRAN
metadata alone, so the planner dropped the names before pak ever saw
them. dep_db() now merges the Bioconductor repositories matching the
running R version into the dependency metadata; enumeration stays on
cran_db(), so the checked set is still CRAN's reverse dependencies.
The install union grows 4406 to 4675 on the most/depth-2 plan, and all
77 previously missing dependencies are in it.

Compile-failure diagnostics: the dmesg watch caught the kernel killing
cc1plus at the per-check cap (anon-rss ~3.3 GiB against the derived
~3.4g) while the manifest said only "fails to install", and
failures.md showed "<00install.out file does not exist>" because
parse_check() looks for it under the container's path. read_side() now
reads the real 00install.out next to the log (tail-capped at 200
lines), and flags compiler kills so the manifest message names the
memory cap instead of the maintainer.

Memory: the per-check cap defaults to 6g instead of the derived
(RAM - 2 GiB) / workers ~ 3.4g. Four workers at 6g overcommit the
15.6 GiB runner deliberately: checks rarely peak together, and the
tight cap was turning Stan/TMB compiles into "install failures".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
…ment the backlog

The check's own compile of the package under test now runs with -g0
appended through the container HOME's ~/.R/Makevars (GNU make reads it
after Makeconf, so `+=` extends the image's flags and the last -g*
wins) and MAKEFLAGS=-j1: debug info is where a template-heavy Stan/TMB
translation unit spends most of its compiler memory -- the dmesg watch
caught cc1plus OOM-killed at ~3.3 GiB anon-rss under the old derived
cap -- and one compiler process per check keeps the 6g cap sized for
one cc1plus rather than a package Makefile's idea of parallelism.
Both halves get identical flags by construction, so the comparison
stays fair; REVDEPX_CHECK_FLAGS='-g' restores CRAN's own flags, and
REVDEPX_CHECK_MAKEFLAGS overrides the make parallelism.

The README gains a measured backlog: the universe membership threshold
(the install union is so long-tailed that 1696 of 4675 packages are
needed by exactly one shard -- baking only packages ≥ 1/4 of shards
need would shrink the image to ~1151 packages for a ~218-package
per-shard delta install), the shard-count layer question (simulation
over the measured durations says full waves beat slack counts, which
is the rule the planner already implements), further compile-memory
switches, and surfacing memory verdicts in the report summary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
claude and others added 8 commits August 19, 2026 13:53
packages=broken re-checks the 204 packages the committed run-9 report
lists as not ok (49 failed, 121 depmissing, 21 newly broken, 6
timeouts, the deferred and errors), now under the Bioconductor-aware
universe, the 6g per-check cap, -g0/-j1 check compiles, and the
00install.out/compiler-kill diagnostics. Revert this commit after the
run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
…the base image

Two findings from the broken-packages retest (run 32260705703):

collect.R wrote manifest.json and README.md whole from this run's
entries, so a subset run -- packages: broken, an explicit list, a part
-- shrank the durable record from 3435 rows to its own 204. Rows for
packages outside the run's plan are now kept from the committed
manifest, marked carried; planned packages stay ineligible, so a dead
shard still reads as `missing` instead of hiding behind a stale row.

The 00install.out capture paid for itself on first contact: all six
remaining install failures (a5R, caugi, osmnxr, RPesto, spopt,
zoomerjoin) are "sh: 1: rustc: not found" -- one missing toolchain,
not memory. The base image now carries Rust via rustup (current
stable, frozen at build time; Ubuntu 24.04's apt rustc 1.75 is below
caugi's 1.80 floor), system-wide under /opt/rust with the proxies
symlinked into /usr/local/bin. The recipe hash rolls the base tag, so
the next run rebuilds base and universe once.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
Run 32260705703 (packages: broken) replaced the committed manifest
with its own 204 rows -- the collect.R bug fixed in the previous
commit. Restored by merging run 32158907637's full record with the
retest's fresh rows: 3402 ok, 25 newly broken, 6 failed (all rustc),
1 error, 1 timeout. problems.md, failures.md and README.md were
already correct -- they are assembled per package, and every
non-retested package is ok and appears in neither.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
tcltk is a *base* R package: pak's sysreqs machinery never sees it as
a dependency, so libtcl only ever reached the universe image through
some other package's system requirements. Run 32281237129 built a
universe for 33 packages, none of whose sysreqs pulled tcl in, and
Boptbd, optbdmaeAT and optrcdmaeAT -- which lazy-load tcltk at install
time -- failed to *install* with "libtcl8.6.so: cannot open shared
object file", after installing fine on every full universe. The base
image already carries Xvfb and X fonts precisely for Tk-using checks;
now it carries the tcl/tk libraries those checks dyn.load too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
…re sections

The subset-merge carry fed payload-less rows into comparison_of(),
whose only shape for a missing payload was an rcmdcheck error -- so
revdepcheck classified all 3402 carried ok rows as "failed to check",
run 32281237129's README announced "Failed to check (3407)", and a
28-line "Not checked (ok)" failure section appeared for every one of
them (failures.md grew by 95916 lines).

Two guards in collect.R: a carried ok row without a payload becomes a
clean two-sided comparison (status "+", zero rows -- counted by the
summary, invisible to every table; verified against rcmdcheck
locally), and the section writer skips payload-less carried rows
entirely -- their committed sections, where they exist, are already
the better evidence. Carried not-ok rows keep the error shim:
"failed to check, not by this run" is the closest bucket the report
vocabulary has.

The committed report is scrubbed in the same commit: the 3400 phantom
sections deleted, failures.md reassembled from the 7 real ones,
README.md's failed table filtered back to the true 5 rows, cran.md's
count and list corrected. manifest.json was correct throughout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
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.

2 participants