Run the Harness end-to-end integration target on every PR

Draft
VA
2d ago
+39-1

Problem

effects::effect_request_and_result_bodies_never_enter_stream (Harness integration target main) failed on main from 7a4414bc6 (#513) until #851 landed the root-cause fix. That fix hashes length-framed binary fields for the effect root, replacing decimal JSON digest serialization whose byte work depended on the digest digits. It passes on current main 2ecd4f21a (bb1, 1 passed, 0.07 s).

Why CI missed it:

  • PR CI (SDK Static) compiles no Harness code by design (#374). It checks the lockfile and lane artifacts, and the aarch64 job type-checks acyclic-fs.
  • The only workflow that runs Harness tests is release qualification (nextest --workspace), which is reachable only through release-acyclic.yml. Its last run was 2026-09-25, three weeks before #513 merged on 2026-10-16.
  • Lanes run focused tests for the code they change. Nothing ran the end-to-end main target, which aggregates 21 cross-subsystem scenarios.

Fix

  • A new SDK Harness integration job in the SDK Static workflow runs the whole main target on every PR and main push: cargo test --locked -p acyclic-harness --features filesystem-local,test-support --test main.
  • It runs on the free GitHub-hosted ubuntu-24.04 runner (the repo is public), so there's no paid CI. The SDK Static job itself is unchanged, with the same 1-minute, no-compile assertions.
  • The job isn't a required check. Main's only required check is readme-guard.
  • scripts/tiny-ci.test.mjs pins the job's exact command, its free hosted runner, and the absence of a job condition or continue-on-error. scripts/check-workflow-runners.mjs records the new hosted runner.

Class closed: a commit breaking an end-to-end Harness scenario can no longer merge without a red check on its PR. The policy test also stops the gate from being silently narrowed (a test filter), softened (continue-on-error), limited to main, or moved to a paid runner.

Known red on landing

This job will be red on this PR and on main until a separate fix lands. native_media_boundary::live_original_options_and_actual_native_parts_reach_adapter_without_conversion in the same target fails on current main: Failed { message: "unauthorized: task has no owner-bound content writer" } at native_media_boundary_cases/live.rs:688 (bb1: 43 passed, 1 failed, 38.9 s). It's the same class of escape. First bad commit is 108fa5e52 (#711, "Publish task compaction summaries in authorized private files"); its parent bfbaaf2b3 (#773) passes (bb1 probes: 0 passed / 1 failed vs 1 passed). The fix will be its own PR.

Verification

  • This PR's own SDK Harness integration run (cold, no cache) took 12m10s: an 11m27s build, then 43 passed and 1 failed (the #711 native-media test) in 28.3 s. So the gate compiles on a bare free runner and fails on the real regression.
  • On bb1, on main 2ecd4f21a, canonical native features: effects:: passed (1 test, 0.07 s). The whole main target ran 43 passed and 1 failed (the native-media test above), with a 38.9 s suite.
  • On bb1: node --test scripts/tiny-ci.test.mjs scripts/check-workflows.test.mjs scripts/static-ci.test.mjs scripts/static-policy.test.mjs passed 23 of 23 in 1.4 s, and node scripts/check-workflow-runners.mjs passed.
  • Negative controls in the new policy test: a test-name filter, continue-on-error, a main-only if, and a Blacksmith runner each make verify throw.
  • No Rust changes, so no Windows check applies.

Review fixes (eeea93e7e)

  1. Synchronized output and the cursor are journaled. RestoreGuard now journals two undos before a frame's first byte: closing the 2026 update and showing a hidden cursor. When a write fails, the update is closed at once, bounded by the device deadline. A timeout, panic or exit restores it through the journal. lifecycle::restore now recovers a poisoned lock; this is safe because the journal is always written before the bytes it covers.
    • Tests in tests/terminal_frame_faults.rs inject faults at every byte of a three-frame session: a transient timeout, a persistent failure until teardown, and a writer panic. An alacritty oracle checks that no update is left open and the cursor is visible afterwards. A transient error must also leave the update closed immediately.
    • Negative control: the unjournaled device path must fail these checks.
    • lifecycle::poison_tests covers restoration after a panic while the lock is held.
  2. Teardown shows the cursor. FrameBackend's destructor presents ratatui's queued cursor restoration. If the device is already closed, the journal has shown the cursor.
  3. No spinning on macOS. Darwin now waits for writability with level-triggered kqueue, which has no descriptor limit. It falls back to select only when kqueue rejects the /dev/tty alias with EINVAL; after #869, which opens the real device, that path is unused.
  4. Traced native write paths.
    • The write loop is now write_until over a NonblockingWrite trait. A scripted trace for EAGAIN/EINTR must equal the expected one exactly. Negative control: the removed 1 ms sleep loop fails it.
    • A real PTY test writes a 256 KiB frame against a late reader, with the descriptor at its normal number and above 1024. Every readiness wait must be followed by a write that makes progress. Negative control: the removed yield_now wait fails it.
    • The Windows console now gets bounded calls of CONSOLE_CHUNK = 256 KiB, each under its own 100 ms deadline. Measured on the local console, per call: 16 KiB 1 ms, 64 KiB 3 ms, 256 KiB 10-17 ms, 1 MiB 38 ms, 4 MiB 110 ms (timed out). Total time for 4 MiB by call size: 4 KiB 289 ms, 64 KiB 114 ms, 256 KiB 99 ms, 1 MiB 98 ms.
    • A plan test checks frames up to 16 MiB: ceil(len/chunk) calls, each within the bound, inside one update. Negative control: a single unbounded call fails it. A failed part is closed by the abort.
    • On the real console, 1, 4 and 16 MiB frames present without error in 26, 98 and 380 ms.
    • Structurally, the device decides synchronized output from the probed modes, so FrameTerminal::new no longer takes a flag.

Real terminal at this head (release, every row repainted; old path → frame path). Brackets aren't counted because these harnesses don't negotiate:

  • Linux bb1 under load (14 concurrent builds): 120x40 5104 → 1 writes, 26627 → 622 us. 240x70 9206 → 1 writes, 40851 → 1254 us.
  • macOS ivar: 120x40 5104 → 1 writes, 2631 → 185 us. 240x70 9206 → 1 writes, 4702 → 374 us. Before kqueue, 240x70 was 906 us.
  • Windows local: 120x40 4699 → 1 writes, 306403 → 407 us. 240x70 8499 → 1 writes, 558224 → 706 us.

Pre-existing failure, not caused by this PR: term::session::tests::delayed_old_state_drop_preserves_reopened_pty fails on macOS on main too, with flags 0x10000 added. Reproduced with main's files on ivar.

Decisions

  • The job covers the main target only, not --tests (37 targets, about 37 test-binary links). main is the aggregated end-to-end target where this escape happened.
  • Features are filesystem-local,test-support, the smallest set that compiles every main module (local_conversation_fork needs filesystem-local). It avoids grpc, search-http and tui system dependencies on a bare runner.
  • There's no build cache. GitHub's 10 GB cache is shared with the qualification caches, and a cold build keeps the job self-contained. The job needs its first measured duration before caching is considered.

🤖 Generated with Claude Code