Skip to content

ci: Linux workflows with a gate on unrecorded divergence - #18

Merged
myobie merged 10 commits into
mainfrom
ci/conformance-gate
Sep 6, 2026
Merged

ci: Linux workflows with a gate on unrecorded divergence#18
myobie merged 10 commits into
mainfrom
ci/conformance-gate

Conversation

@myobie

@myobie myobie commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

The repository has no CI. The conformance suite can already compare the two implementations, and nothing runs it. Parity is measurable, it is measured well, and it is not monitored.

Two workflows, both ubuntu-latest, shaped like the Node pty's nix.yml and test.yml so a person moving between the repositories reads one pattern.

What gates, and what does not

Check Gates?
nix build and the binary runs yes
cargo build --workspace --release yes
Workspace tests yes
Conformance suite against BOTH binaries yes
Unrecorded divergence yes
Formatting no — reports a count
Clippy no — reports a count

cargo fmt --check reports 1093 diffs on main and clippy reports 50 warnings (measured 2026-09-05). Neither has ever passed. Gating either means a cleanup commit first, which is a separate decision from adding CI, and a large reformat would collide with in-flight branches. Both run and print their counts into the job summary, so the debt is visible on every run rather than hidden behind a check nobody enabled.

The divergence gate

scripts/conformance-both.sh writes the tests whose result differs between the binaries. scripts/check-divergences.py compares that against crates/pty-conformance/divergences.toml and fails in both directions:

  • a difference nobody has recorded;
  • a record that no longer happens.

The second rule is the point. A ledger that only grows becomes a list of claims nobody re-reads, and its entries outlive their reasons. Four controls verify the gate actually fails: an unrecorded rust-red regression, a stale entry, a flipped side, and an empty result against a seeded ledger. A gate nobody has watched fail is a gate nobody knows works.

The Node commit is pinned, and I found out why the hard way

crates/pty-conformance/node-ref holds the reference commit; the workflow checks it out and prints it.

I learned this requirement by making the mistake. Earlier on 2026-09-05 I measured against a local Node checkout seven commits behind, because I cloned from it instead of fetching. It produced eight divergences, every one an artifact, and I reported them before a peer noticed the hash disagreed. Without a pin, this workflow manufactures exactly that error and somebody records the result as intended.

The pin also makes the ledger stable: bumping node-ref and clearing resolved entries go together, and the gate enforces it. When the Node fix below lands and someone bumps the pin, the two seeded entries become stale and CI says so.

The seeded ledger: two entries, one defect

Measured against Node 86dcc5e on 2026-09-05: pty-rust 660/660, the Node pty 658/660. Those two are the entire difference between the implementations.

Both are one Node-side defect. The Node binary truncates pty completions fish when stdout is a pipe — 123 of 167 lines piped, all 167 to a file; completions zsh the same. It is Node exiting before an async pipe drains, not a content difference: the checked-in artifacts and pty-rust output are byte identical. So pty completions fish | source, the normal fish idiom, silently writes a broken file. Reported to the Node pty owner, who has a fix on a branch.

Neither is an intended divergence, so the ledger records kind = "node-defect" rather than calling a bug a decision.

Flakiness is handled the way the README already prescribes

The first run of this workflow failed on security_fixes::concurrent_stealers_cannot_both_win. That is the flakiness the README documents: 139 test binaries run in parallel driving real processes through real PTYs, and a runner is a loaded machine. Re-run alone, that binary passes 8 of 8.

scripts/ci-test-workspace.sh encodes the README's own rule — "re-run it alone before treating it as one" — by re-running only the failing binaries, single-threaded. A real defect still has to lose twice, once under contention and once alone.

What it would have caught this week

Nothing, and I want to be exact rather than let it read scarier.

My earlier answer said it would have caught PR #12 on 2026-09-04. That was wrong, and it was wrong because of the stale reference above: Node shipped the same keep-expiry feature the same day in a2ae2db. The implementations agree.

So this would not have caught a regression this week, because nobody shipped one. What changes is that the next one is caught by a machine on the pull request, instead of by someone running the suite by hand after being asked a question.

macOS is deliberately not covered

Both workflows are Linux. Nothing in CI builds or tests this on a Mac.

crates/pty-core/src/proctable.rs carries a macOS process-table reader that no Linux job compiles: libproc, plus a sysctl fallback with hand-declared kinfo_proc struct offsets. Offsets are exactly what a new macOS moves. A change there, or an SDK change, breaks the Mac build and CI will not notice.

That is accepted, not overlooked: the tool runs on two Macs every day, so a broken Mac build surfaces immediately in use, and human use is the detection mechanism. The trade holds while the daily users are the affected users. The reasoning is recorded in the README so whoever hits that failure finds a decision rather than an oversight, and can revisit it cheaply.

Not in this pull request

  • docs/conformance.md was generated against the older Node and is stale; five Node suites postdate it. The behaviour is covered by existing Rust tests, the pins are not. Regenerating it is follow-up.
  • Nothing compares the two session-manager TUIs. Unchanged, and this does not address it.

The repository had no CI. The conformance suite could compare the two
implementations, and nothing ran it. A divergence could reach main and no
machine would object.

The workflow builds the Node pty at a pinned commit, runs the conformance
suite against both binaries, and fails when a difference is not recorded in
the ledger.

The pin matters. A local Node checkout seven commits behind produced eight
false divergences on 2026-09-05, and every one was an artifact of the stale
reference. The workflow reads the commit from crates/pty-conformance/node-ref
and prints it.

check-divergences.py fails in both directions: on a difference nobody has
recorded, and on a record that no longer happens. A ledger that only grows
stops describing the present. Four controls cover both directions, a flipped
side, and an empty result.

The ledger is seeded with the two real differences. Both are one Node defect:
the Node binary truncates `pty completions fish` when stdout is a pipe, at
123 of 167 lines, and writes all 167 to a file.

Formatting and clippy report their counts and do not gate. main does not pass
either today: 1093 fmt diffs and 50 clippy warnings. Making either a gate
needs a cleanup commit, which is a separate decision from adding CI.
Nathan decided CI is Linux only and pointed at the Node pty's workflows as the
model. This replaces the single ci.yml with nix.yml and test.yml, both on
ubuntu-latest, both triggered the same way, using the same nix action.

The macOS job is gone. The decision and its reason are recorded in the README:
proctable.rs carries a macOS process-table reader with hand-declared struct
offsets that no Linux job compiles, so an offset or SDK change breaks the Mac
build and CI will not notice. That is accepted because the tool runs on two
Macs every day, so human use is the detection mechanism.

The first run failed on security_fixes::concurrent_stealers_cannot_both_win,
which is the flakiness the README already documents: 139 test binaries run in
parallel driving real processes, and a runner is a loaded machine. Re-run alone
here, that binary passes 8 of 8.

So scripts/ci-test-workspace.sh encodes the procedure the README already
prescribes. It re-runs only the failing binaries, single-threaded, and fails if
they fail again. A real defect still has to lose twice.
@myobie myobie changed the title ci: add a workflow that fails on an unrecorded divergence ci: Linux workflows with a gate on unrecorded divergence Sep 5, 2026
The first run timed out. The conformance step needs about 45 minutes on a
two-core runner, and I had set a 60 minute job timeout from a 30 minute
measurement taken on a many-core machine with a warm cache. The gate was not
what failed. My estimate was.

Conformance now has its own workflow, so build, lint counts and the workspace
tests report in about 12 minutes instead of queueing behind it. Four shards
each take a slice of the files and run BOTH binaries on that slice, which
keeps the two sides on one runner: this suite races under load, and giving a
shard another binary's work is the way to make that worse.

The divergence check runs ONCE, over the combined set. It cannot move into the
shards. The check fails in two directions, and the second one, a recorded
divergence that no longer happens, can only be decided by knowing the
divergence appeared in no shard at all. A missing shard is refused rather than
treated as agreement.

Sharding is the right treatment because the cost is the tests. Measured: five
files cost 10.48 s as five cargo invocations and 10.27 s as one, with the
tests accounting for 10.21 s. The work parallelises across machines and does
not compress on one. That measurement, and the reason a single invocation
needs --no-fail-fast, are recorded beside the loop they describe.
Shard 2 hit the 45 minute timeout. It printed its file list and then produced
nothing for 41 minutes, so there is no evidence of which file hung.

The cause is the pipe to `tail -60`. A pipe buffers until the command ends, and
a command killed by a timeout never ends, so the output that would name the
file was discarded. The shard now streams through tee, and the log is kept in
the artifact whether the shard passes or not.

The gate now counts red.txt files rather than directories. A hung shard still
uploads its log, so its directory exists while its result does not, and
counting directories would read that as a complete set.

Three shards passed in about 8 minutes each, doing work one serial job could
not finish in 60, so the shape is right. This is only about being able to see
the failure.
Every shard failed, including three whose work was perfect. Shard 0 finished
152 of 152 on both binaries with no differences and then exited 1.

The cause is the instrumentation I added to diagnose the hang. tee opens its
output file when the pipeline starts, which is before conformance-both.sh
creates its --out directory, so tee died with "No such file or directory" and
set -o pipefail turned a clean run into a failure.

It also cost the evidence it was added to collect: shard 2 hung again for the
full 45 minutes and there is still no streamed log, because tee never opened
one.

The mkdir is the whole fix.
Shard 2 hung three times, each stopping at exactly the 45 minute timeout. The
streamed log finally showed why: it was not slow and it did not fail. It
finished its work in 2 minutes 44 seconds, printed the full table, 172 of 172
on Rust, and then the step sat for another 38 minutes.

At cleanup the runner reported: Terminate orphan process: pid (23143)
(pty-daemon).

The step piped the script into tail, then into tee. A pipe makes completion
depend on end-of-file, and end-of-file needs every write end closed, including
one held by a process that outlives the run. This suite leaves daemons behind.

Writing to a file removes the dependency. It also keeps the partial output when
a step is killed, which the pipe into tail did not: that is why the first two
hangs left no evidence at all.

A heartbeat now names the most recently finished file every 30 seconds, so the
next hang of any kind is visible while it happens.

The leaked daemon is a separate matter and a real one. A tool that leaves a
process holding a caller's inherited descriptor can wedge any pipeline it is
used in, long after its own work is done. That is worth its own investigation
and it is not fixed here.
The gate failed and it was right three times over.

It found an UNRECORDED divergence nobody had written down anywhere: pty-rust
sets PWD in the child environment to the directory as the caller wrote it,
following node-pty's own env.PWD = cwd, and the Node pty does not set it at
all. Where a directory is reached through a symlink the two disagree, so
pty run --cwd X answers one way from inside X and another from outside. It is
in neither docs/parity.md nor a decision record. It is now in the ledger.

It also called both seeded entries STALE, and they were. I measured the Node
completions truncation on Node v24.18.0, where it reproduces every time, 123
lines of 167. CI runs Node 22 and does not reproduce it at all: same commit,
same test, different answer.

So the reference COMMIT is not the only input that decides the answer. The
runtime is too, and I had pinned one and not thought about the other. The
workflow now says so where the version is set.

The defect is real on Node 24 and the Node pty owner has a fix. The entries are
removed because a ledger records what CI can see, and kept in the header so the
knowledge is not lost with them.
I recorded it as version-dependent, which implies the Node major decides it.
The Node pty owner measured the same unfixed binary on arm64 macOS and got the
full 12138 bytes under both 22.21.1 and 24.0.2.

So runtime, platform and scheduling decide it together. My measurement was one
environment: x86_64 Linux, Node v24.18.0, 123 lines of 167 every run. The only
claim I can defend is that it does not reproduce where this gate runs.

The header now says that, and records their forced delayed-write control:
zero bytes against the old exit path, 12138 against the fixed one, on 22 and 24
alike. That control does not depend on scheduling, so it proves the fix on a
runtime where the natural failure never appears.

Comment only. The recorded entry is unchanged.
The gate failed on two differences that were not differences. A run reported
nesting_prevention::restart_force_restores_the_attach and
up_down::down_stops_only_named_sessions as Node-side failures. Both files then
passed 3 of 3 locally against the same Node binary. They were lost races.

The previous single-job workflow re-ran differing files before failing. I
dropped that when I split the run into shards, and this is the consequence.

The retry belongs in the shard, where the binaries and the toolchain already
exist, so it goes there. It re-runs only the differing files, which is both
cheap and much less contended than the full shard, and rewrites red.txt. A
file that was clean contributes nothing to red.txt either way, so re-running
just the differing ones is still a complete result for the shard.

A real difference is reproducible and survives. A race has to lose twice.

The empty-red.txt guard matters more than it looks: conformance-both.sh with no
file arguments runs all seventy, so an unguarded retry on a clean shard would
quietly double the work. I hit that while testing this by hand.
The comment said the cost is the tests, from a five-file sample that happened
to contain the one seven-second outlier. Across all 140 invocations the
reported test time totals 5.1 minutes, no invocation exceeds 12.5 seconds, and
the compile before the first result took about 26.

The conclusion is unchanged, because sharding parallelises the compile too. The
reason was wrong, and a wrong reason in a durable comment sends the next person
optimising test runtime, which has almost nothing to give.
@myobie
myobie merged commit c484b78 into main Sep 6, 2026
7 checks passed
@myobie
myobie deleted the ci/conformance-gate branch September 6, 2026 08:55
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.

1 participant