From 595408a4fb4546e3a7848a5ffcc92c065ed1941f Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Mon, 31 Aug 2026 13:45:45 +0200 Subject: [PATCH 1/3] fix(nix): unbreak `nix build`, and let it run on the PRs that can break it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `nix build .#openscreen` has failed for everyone since 2026-08-30. It never reached a derivation of ours: `cargo-vendor-dir` died fetching every crate in the lockfile from `https://crates.io/api/v1/crates///download`, which crates.io now answers with 403 — it rate-limits that endpoint to one request per second and points clients at the CDN instead (rust-lang/crates.io#13482). Forty-seven crates, forty-seven 403s, and the same death on `Nix build` runs 33325132923 and 33325656407 on main. nixpkgs fixed it in `importCargoLock` by switching to `https://static.crates.io/crates`. The pin here was from 2026-04-09 and predated that, so this rolls it to d2f6794 (2026-08-29) and says in `flake.nix` why it must not roll back. Verified by building it: `nix build .#openscreen` succeeds. The addon is worth naming, because #371 had to merge with it unverified for exactly this reason — `libavfilter.so.12` is staged beside `compositor_view.node`, exactly one `libavfilter.so.`, `osff_avfilter_graph_alloc@@LIBAVFILTER_12` and friends defined there and nowhere else, no un-renamed `avfilter_*` leaking, ldd fully resolved, and `require()` returns the addon's exports. That is the failure mode the #371 review feared, and it does not happen. `nix-build.yml` now also runs on pull requests touching flake.*, nix/, crates/ or package-lock.json. It is the only job that builds the derivation at all — `nix-check.yml` compares npmDepsHash and nothing else — so a PR rewriting the addon's source filter, its RPATH handling or its symbols.map went green on ~18 checks without one of them building it, and the first real signal arrived on main half an hour after the merge. Path-filtered because the job takes half an hour, and cancel-in-progress on PRs only, so a re-push does not queue a second one while keeping main's runs to completion. --- .github/workflows/nix-build.yml | 22 +++++++++++++++++++++- flake.lock | 6 +++--- flake.nix | 10 ++++++++++ 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/.github/workflows/nix-build.yml b/.github/workflows/nix-build.yml index a8531c9ac..c18c2ea79 100644 --- a/.github/workflows/nix-build.yml +++ b/.github/workflows/nix-build.yml @@ -24,6 +24,22 @@ name: Nix build # front of every merge. Promote it once the schedule has reported a few times. on: workflow_dispatch: + # The job that actually builds the derivation now runs on the pull requests that + # can break it. It did not, and the gap was not academic: `nix-check.yml` only + # compares npmDepsHash, so a PR rewriting the addon's source filter, its RPATH + # handling or its symbols.map went green on ~18 checks without one of them + # building it, and the first real signal arrived on main half an hour after the + # merge. #371 shipped a change to `nix/compositor-view.nix` that way. + # + # Path-filtered rather than universal: this takes about half an hour, and a PR + # that touches none of these files cannot change what it produces. + pull_request: + paths: + - flake.nix + - flake.lock + - nix/** + - crates/** + - package-lock.json push: branches: [main] schedule: @@ -56,7 +72,11 @@ concurrency: # That is the affordable half. Verifying each merge would need a queue this # workflow does not have, and is not worth it for a half-hour job whose purpose # is catching drift rather than gating a commit. - cancel-in-progress: false + # + # On a pull request the opposite is right: a new push makes the previous run + # answer a question nobody is asking any more, and at half an hour each they + # would pile up. `github.ref` is the PR's merge ref, so the group is per-PR. + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: build: diff --git a/flake.lock b/flake.lock index 77972fb40..dac7a986d 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1775710090, - "narHash": "sha256-ar3rofg+awPB8QXDaFJhJ2jJhu+KqN/PRCXeyuXR76E=", + "lastModified": 1788039129, + "narHash": "sha256-pa4Q0qErvCvzCaaUph7Sm37RhR4xvPrYI8Lgz6k85+A=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "4c1018dae018162ec878d42fec712642d214fdfa", + "rev": "d2f67949798825fe853f7c5d0492b8bf016d3f88", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index be01a58b4..255f81c6f 100644 --- a/flake.nix +++ b/flake.nix @@ -2,6 +2,16 @@ description = "OpenScreen — desktop screen recorder with built-in editor"; inputs = { + # Do not roll flake.lock BACK past nixpkgs d2f6794 (2026-08-29). Before it, + # `importCargoLock` fetched every crate from + # `https://crates.io/api/v1/crates///download`, which crates.io + # now answers with 403 — it rate-limits that endpoint to 1 req/s and points + # clients at the CDN instead (rust-lang/crates.io#13482). Every crate in the + # lockfile failed, so `nix build` died in `cargo-vendor-dir` before reaching a + # single derivation of ours: `Nix build` was red on main from 2026-08-30, and + # since `nix-check.yml` only compares npmDepsHash and `nix-build.yml` did not + # run on pull requests, nothing about nix/ was being verified at all. + # d2f6794 carries the switch to `https://static.crates.io/crates`. nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; }; From 5a64b3413442007b67be2a5b898d81e6eaf65476 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Mon, 31 Aug 2026 15:50:53 +0200 Subject: [PATCH 2/3] fix(nix): trigger on this workflow's own edits, and narrow an overclaim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit, both fair. The path filter did not list `nix-build.yml` itself, so a PR editing only this file got no validation of the change it was making — nix-check.yml already lists itself for that reason. And the comment in flake.nix said "nothing about nix/ was being verified at all" one clause after conceding that nix-check.yml compares npmDepsHash. The real gap is narrower and worth stating precisely: the derivation was not being built. --- .github/workflows/nix-build.yml | 3 +++ flake.nix | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/nix-build.yml b/.github/workflows/nix-build.yml index c18c2ea79..894a7f2fa 100644 --- a/.github/workflows/nix-build.yml +++ b/.github/workflows/nix-build.yml @@ -40,6 +40,9 @@ on: - nix/** - crates/** - package-lock.json + # Including itself, or a PR that only edits this file gets no validation of + # the change it is making -- the same rule nix-check.yml already follows. + - .github/workflows/nix-build.yml push: branches: [main] schedule: diff --git a/flake.nix b/flake.nix index 255f81c6f..4a8c39f2f 100644 --- a/flake.nix +++ b/flake.nix @@ -10,7 +10,8 @@ # lockfile failed, so `nix build` died in `cargo-vendor-dir` before reaching a # single derivation of ours: `Nix build` was red on main from 2026-08-30, and # since `nix-check.yml` only compares npmDepsHash and `nix-build.yml` did not - # run on pull requests, nothing about nix/ was being verified at all. + # run on pull requests, the derivation itself was not being built anywhere -- + # not before a merge, and not after one either while this was red. # d2f6794 carries the switch to `https://static.crates.io/crates`. nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; }; From 410fcc885df98e43f7fe78d28cf67a465f1cb7ef Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Mon, 31 Aug 2026 22:50:57 +0200 Subject: [PATCH 3/3] ci(nix): prove the addon by exporting a synthesised clip, not by recording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Smoke-test the built binary` has never passed. Runs 32707512544 (24/08) and 32827253816 (25/08) failed here in the 34-36 minutes they took to get past the build, long before the crates.io 403 started killing it at 80 seconds. It asked for a capability this host does not have. `record` needs a display index; Chromium's X11 capturer logs "screen_capturer_x11.cc: Failed to initialize pixel buffer", `sources` answers `displays: []`, and record dies on "Display index 0 not found (0 screen(s) available)". Export therefore never ran, and the step that exists to vouch for the compositor addon vouched for nothing. Measured against the built artefact under Xvfb before rewriting anything. With `xvfb-run -a` as this workflow invokes it: 4 runs in 10 saw a display. Starting Xvfb by hand and polling xdpyinfo until the server answered before launching the app: 3 in 10. So it is not the startup race it looks like — waiting for the server changes nothing — and whatever it is lives inside Chromium's X11 capturer. On the runner it comes up zero every time rather than a third of the time. So the input stops being a recording. ffmpeg synthesises two seconds of H.264 in MP4 with an AAC track, a three-line project points at it, and export renders that. It proves the same thing — the packaged addon loads, decodes, composes through Vulkan and muxes an MP4 — and asks for nothing a headless runner will ever have. Measured 6/6 locally where record measured 4/10, and the whole step rehearsed end to end against a real `nix build` result: enumeration 5/5, export 1, exit 0. Export goes back to being a hard gate on every trigger. The pull-request downgrade this branch carried for two commits is gone with the dependency that made it necessary; nothing is excused any more, because nothing needs to be. Capture is still worth watching, so one probe still runs — informational, one attempt instead of three, gating nothing, and it says so out loud if it ever starts working. --- .github/workflows/nix-build.yml | 152 ++++++++++++++++++-------------- 1 file changed, 86 insertions(+), 66 deletions(-) diff --git a/.github/workflows/nix-build.yml b/.github/workflows/nix-build.yml index 894a7f2fa..e2ae200cb 100644 --- a/.github/workflows/nix-build.yml +++ b/.github/workflows/nix-build.yml @@ -444,84 +444,100 @@ jobs: fi # The real acceptance test. Everything above proves the package starts - # and can list a screen; none of it touches the compositor addon, which - # is what actually renders output. Record a couple of seconds, export it, - # and look at what came out. + # and can answer an enumeration call; none of it touches the compositor + # addon, which is what actually renders output. # - # This block was briefly moved ahead of the sources loop and moved back, - # so that it is not tried a third time. The theory was that position - # explained why record seemed to fail far more often than sources -- - # run_cli spawns a fresh `xvfb-run -a` each time, so record was always - # invocations 6-8, after five Xvfb servers had come and gone. The - # experiment could not answer it: by the time it ran, record had started - # succeeding from its old position anyway, so there was no contrast left - # to measure. From position 4 it succeeded, which proves nothing it was - # not already doing from position 9. + # It used to record two seconds and export the result. That never once + # worked here. `record` needs a display index and this host has no + # display to give: Chromium's X11 capturer logs + # "screen_capturer_x11.cc: Failed to initialize pixel buffer", `sources` + # answers with `displays: []`, and record dies on "Display index 0 not + # found (0 screen(s) available)". So export never ran, and the step that + # exists to vouch for the addon vouched for nothing -- runs 32707512544 + # (24/08) and 32827253816 (25/08) failed exactly here, in the 34-36 + # minutes they took to get past the build. # - # What the runs did establish is that the premise was wrong. Enumeration - # here is bimodal -- 12-31ms when it answers, no return at all when it - # does not, with nothing in between across every measurement so far -- - # and the failures cluster by run and by window within a run rather than - # by command. The apparent record-versus-sources gap was that clustering - # seen through a denominator, not a property of either path. Reopen this - # with the run_cli labels, on a run that actually fails, before assuming - # otherwise. + # Measured before rewriting it, against the built artefact under Xvfb: + # with `xvfb-run -a` as this workflow invokes it, 4 runs in 10 saw a + # display; starting Xvfb by hand and polling xdpyinfo until the server + # answered before launching the app, 3 in 10. So it is not the startup + # race it looks like -- waiting for the server changes nothing -- and + # whatever it is lives inside Chromium's X11 capturer. On this runner it + # comes up zero every time rather than a third of the time. # - # Up to three goes, because screen capture on this host is unreliable in - # its own right. One success is enough for the question being asked here. - echo "--- record then export (first run_cli here is #$((RUN_CLI_N + 1))) ---" - EXPORTED="" - # Tracked apart from EXPORTED so the verdict can name the stage that - # actually failed. For three runs every attempt died in record without - # export ever executing, while the annotation said "the export path does - # not work" -- an accusation aimed at the one component the run never - # reached, and the compositor addon is precisely what this step exists - # to vouch for. - RECORDED=0 - for i in 1 2 3; do - echo "=== export attempt $i/3 (run_cli #$((RUN_CLI_N + 1))) ===" - rm -f /tmp/demo.openscreen /tmp/demo.mp4 - RC=0 - CLI_TIMEOUT=120 OPENSCREEN_DIAGNOSTIC=1 run_cli $SANDBOX $CHROME_FLAGS record --duration 2 --project /tmp/demo.openscreen >"/tmp/rec.$i.out" 2>&1 || RC=$? - # Outside the failure branch for the same reason as above: a record that - # works is exactly the measurement missing from the comparison, since - # this path has never yet produced one. - grep -a "get-sources\]" "/tmp/rec.$i.out" || true - if [ "$RC" -ne 0 ] || [ ! -f /tmp/demo.openscreen ]; then - echo "record failed (rc=$RC); last lines:" - tail -5 "/tmp/rec.$i.out" || true - continue - fi - RECORDED=1 - echo "recorded. project:" - head -c 200 /tmp/demo.openscreen; echo + # So the input stops being a recording. ffmpeg synthesises two seconds of + # video, a three-line project points at it, and export renders that. + # Identical in what it proves -- the packaged compositor addon loads, + # decodes, composes through Vulkan and muxes an MP4 -- and it asks for no + # capability a headless runner is ever going to have. Measured at 6/6 + # locally where record measured 4/10. + # + # Capture is still worth watching, so one probe still runs. It is + # informational: it cannot pass here, and nothing gates on it. + echo "--- capture probe (informational; run_cli #$((RUN_CLI_N + 1))) ---" + RC=0 + CLI_TIMEOUT=120 OPENSCREEN_DIAGNOSTIC=1 run_cli $SANDBOX $CHROME_FLAGS record --duration 2 --project /tmp/probe.openscreen >/tmp/probe.out 2>&1 || RC=$? + if [ "$RC" -eq 0 ] && [ -f /tmp/probe.openscreen ]; then + echo "::warning::record worked on this runner. Capture is no longer broken here -- see whether the export check below should go back to using a real recording." + else + echo "capture still unavailable (rc=$RC); last lines:" + tail -3 /tmp/probe.out || true + fi - RC=0 - CLI_TIMEOUT=180 OPENSCREEN_DIAGNOSTIC=1 run_cli $SANDBOX $CHROME_FLAGS export /tmp/demo.openscreen -o /tmp/demo.mp4 >"/tmp/exp.$i.out" 2>&1 || RC=$? - if [ "$RC" -ne 0 ] || [ ! -f /tmp/demo.mp4 ]; then - echo "export failed (rc=$RC); last lines:" - tail -15 "/tmp/exp.$i.out" || true - continue - fi - EXPORTED=/tmp/demo.mp4 - break - done + echo "--- synthesise a clip and export it (first run_cli here is #$((RUN_CLI_N + 1))) ---" + # From the flake's own nixpkgs, for the same reason the Vulkan ICD is: + # the ambient registry drifts, and a decoder that is never the same twice + # is drift injected into a check that exists to catch it. + FFMPEG=$(nix build --no-link --print-out-paths --inputs-from . nixpkgs#ffmpeg-headless) + FFMPEG=${FFMPEG%%$'\n'*} + echo "ffmpeg: $FFMPEG" + # H.264 in MP4 with an AAC track: the shape a real recording arrives in, + # so the export walks its ordinary decode path rather than a special one. + "$FFMPEG/bin/ffmpeg" -loglevel error -y \ + -f lavfi -i "testsrc2=size=1280x720:rate=30" \ + -f lavfi -i "sine=frequency=440:sample_rate=48000" \ + -t 2 -pix_fmt yuv420p -c:v libx264 -c:a aac -shortest /tmp/demo-src.mp4 + ls -l /tmp/demo-src.mp4 + + # The whole project format the exporter needs: a media path and an empty + # editor, which normalises to a single full-length clip. This is what + # `record --project` writes, minus the parts a recording fills in. + cat > /tmp/demo.openscreen <<'JSON' + { + "version": 2, + "media": { "screenVideoPath": "/tmp/demo-src.mp4" }, + "editor": {} + } + JSON + # Parse it back before handing it over, so a future edit that breaks the + # JSON fails here with a parse error rather than 300 s later as an + # export that could not read its project. + python3 -c "import json;json.load(open('/tmp/demo.openscreen'))" + cat /tmp/demo.openscreen + + echo "--- openscreen info ---" + CLI_TIMEOUT=120 run_cli $SANDBOX $CHROME_FLAGS info /tmp/demo.openscreen || true + + rm -f /tmp/demo.mp4 + RC=0 + CLI_TIMEOUT=300 OPENSCREEN_DIAGNOSTIC=1 run_cli $SANDBOX $CHROME_FLAGS export /tmp/demo.openscreen -o /tmp/demo.mp4 >/tmp/exp.out 2>&1 || RC=$? EXPORT_OK=0 - if [ -z "$EXPORTED" ] && [ "$RECORDED" -eq 0 ]; then - echo "::error::No attempt got past record, so export never ran and the compositor addon is unproven. This is a capture failure on this host, not an export failure." - elif [ -z "$EXPORTED" ]; then - echo "::error::record produced a project but no attempt produced an MP4. The compositor addon is packaged and the export path does not work." + if [ "$RC" -ne 0 ] || [ ! -f /tmp/demo.mp4 ]; then + echo "::error::export failed (rc=$RC). The compositor addon is packaged and the export path does not work." + tail -25 /tmp/exp.out || true else - SIZE=$(wc -c < "$EXPORTED") + SIZE=$(wc -c < /tmp/demo.mp4) # An MP4 opens with a 4-byte length then 'ftyp'. A zero-length or # truncated file would otherwise pass a mere existence check. - MAGIC=$(dd if="$EXPORTED" bs=1 skip=4 count=4 2>/dev/null || true) + MAGIC=$(dd if=/tmp/demo.mp4 bs=1 skip=4 count=4 2>/dev/null || true) echo "exported $SIZE bytes, magic at offset 4: $MAGIC" if [ "$MAGIC" != "ftyp" ]; then echo "::error::output is not an MP4 (no ftyp box)" + tail -25 /tmp/exp.out || true elif [ "$SIZE" -lt 10000 ]; then echo "::error::MP4 is only $SIZE bytes, too small to hold two seconds of video" + tail -25 /tmp/exp.out || true else echo "Export works: $SIZE bytes of MP4." EXPORT_OK=1 @@ -532,7 +548,7 @@ jobs: # flaky must not hide whether export works, which is the whole point of # having packaged the compositor addon. # - # The gate is "did enumeration ever work" and "does export work", not + # The gate is "did enumeration ever answer" and "does export work", not # "did all five attempts pass". Requiring FAILED -eq 0 made the job red # by construction: the standing numbers on this runner are 1/5, 3/5 and # 4/5 ok, so a run where export is perfect and four enumerations succeed @@ -541,7 +557,11 @@ jobs: # per-attempt warnings above keep that flakiness visible without letting # it decide the build; tighten this to $ATTEMPTS once the capture failure # is understood and fixed. - echo "=== verdict: enumeration $OK/$ATTEMPTS ok, record $RECORDED, export $EXPORT_OK, $RUN_CLI_N run_cli invocations ===" + # + # Export, on the other hand, is a hard gate on every trigger again. It no + # longer depends on a capability this host does not have, so there is + # nothing left to excuse: if it fails now, the package is broken. + echo "=== verdict: enumeration $OK/$ATTEMPTS ok, export $EXPORT_OK, $RUN_CLI_N run_cli invocations ===" if [ "$EXPORT_OK" -ne 1 ] || [ "$OK" -eq 0 ]; then exit 1 fi