Skip to content

Introduce conformance test CLI - #346

Closed
henrybear327 wants to merge 5 commits into
sysprog21:mainfrom
henrybear327:conf/harness
Closed

Introduce conformance test CLI#346
henrybear327 wants to merge 5 commits into
sysprog21:mainfrom
henrybear327:conf/harness

Conversation

@henrybear327

@henrybear327 henrybear327 commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Build extensible CLI to host gvisor and LTP (and potentially much more) conformance test suites


Summary by cubic

Introduces a conformance test harness that runs registered Linux suites (gvisor, LTP, and more) against elfuse and a QEMU reference, judging results against JSONC expectations. The scripts/conformance CLI has a fixed capability-first command tree that suite registration cannot extend.

New Features

  • Adds scripts/conformance commands for listing suites, enumerating cases, running lanes, seeding expectations, writing reports, and cleaning up after interrupted runs.
  • Adds elfuse and QEMU backends with process capture, timeouts, and signal classification.
  • Adds JSONC expectation files to declare passes, failures, skips, and quarantines.
  • Adds payload pinning to lock suite artifacts to content hashes.
  • Adds a CI workflow running PR-scoped and full scheduled conformance runs.

Other Changes

  • Updates the QEMU runner to write a state file, fail when guest /tmp setup fails, arm its cleanup per CLI command, and verify a recorded pid still belongs to the VM before stopping; the shared ssh options now live in tests/lib/qemu-ssh.sh.
  • Adds make clean-conformance to sweep leftover VMs, guest processes, and scratch after interrupted runs; lanes record the shared memory segments they create so cleanup removes those and reports the rest.
  • Wires the harness into make check via selftests and adds docs/conformance.md plus the command reference in docs/testing.md.

Written for commit ffa54f4. Summary will update on new commits.

Review in cubic

@henrybear327
henrybear327 requested a review from jserv August 31, 2026 18:20
@henrybear327 henrybear327 self-assigned this Aug 31, 2026
cubic-dev-ai[bot]

This comment was marked as resolved.

@henrybear327
henrybear327 force-pushed the conf/harness branch 3 times, most recently from d07c9e3 to 9482ae7 Compare August 31, 2026 20:28
@henrybear327
henrybear327 marked this pull request as draft August 31, 2026 20:38
@henrybear327
henrybear327 marked this pull request as ready for review August 31, 2026 20:38
Comment thread .github/workflows/conformance.yml Outdated

@jserv jserv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Introduce re-usable shell scripts as common library.

cubic-dev-ai[bot]

This comment was marked as resolved.

@henrybear327

Copy link
Copy Markdown
Collaborator Author

Introduce re-usable shell scripts as common library.

Extracted the SSH part out.

@henrybear327
henrybear327 force-pushed the conf/harness branch 4 times, most recently from 417be2a to f303d4b Compare September 1, 2026 20:17
@henrybear327
henrybear327 requested a review from jserv September 1, 2026 20:49

@jserv jserv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rebase latest main branch and resolve conflicts.

@henrybear327
henrybear327 marked this pull request as draft September 2, 2026 16:01

@jserv jserv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The old source-time trap 'qemu_stop' EXIT clobbered the cleanup_fixtures; cleanup_qemu trap that test-matrix.sh registers earlier at line 152, since a bare trap ... EXIT replaces the chain rather than adding to it.

run: |
[ "$DISCOVER" = success ]
[ "$HARNESS" = success ]
if [ "$SUITES" = '[]' ]; then

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The registry is empty on this branch, so suites returns [], the payload/qemu/elfuse jobs all skip, and this branch asserts that skip as success. The required check is green while nothing runs, and it stays green if the last provider is ever dropped from the registry.

Once the first suite lands this needs a floor: assert the discovered list is non-empty, or compare it against a checked-in expected inventory.

Comment thread tests/conformance/seed.py
out: Dict[str, List[str]] = {}
for (suite, group), members in sorted(by_group.items()):
actions = {wanted.get(m) for m in members}
if whole_groups and len(actions) == 1 and None not in actions and len(members) > 1:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

whole_groups is set for any run whose scope is not cases, so a --scope pr run collapses to suite:group/* even when it covered only part of the group (a pr subset, or an entry with only filters). Two failing cases out of a larger group seed s:g/*.

The untouched siblings then inherit expect_failure, and the next full run reports them as unexpected_pass. Collapse only when the run actually covered the whole group.

Comment thread docs/conformance.md
```

Selectors and expectation matchers use shell globs across the complete ID.
A bare group selector also selects its cases. An unmatched selector is an

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True for --case selectors, which selection.resolve_ids expands to pattern plus pattern/*, but not for expectation matchers: expectations.resolve calls fnmatch directly, and ids.matches("s:g", "s:g/c") is False.

So a matcher naming a bare group silently covers nothing, and only a full-scope run's stale() reports it. Either split this sentence per consumer, or give resolve the same expansion.

if lost:
return Invocation(execution="transport", wall_us=inv.wall_us,
stdout=str(out_path), stderr=inv.stderr)
timed_out = rc == 137 and inv.wall_us >= timeout_s * 1_000_000

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tiebreaker compares the guest's exit status against the host's total ssh wall time, which also covers connect, auth and teardown, so it can pass timeout_s while the guest command itself ran for a fraction of that. A case killed by SIGKILL for an unrelated reason (guest OOM, the suite killing its own child) then records TIMEOUT instead of CRASH.

Both statuses are red and both seed skip, so the cost is the recorded status rather than the verdict. An explicit marker printed by the remote script when timeout fires would make it exact.

try:
# The lock lives in a shared namespace, so another uid may own it.
self.lock_file.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(self.lock_file, os.O_RDWR | os.O_CREAT, 0o600)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ElfuseBackend.lock_file is /tmp/elfuse-conformance-<uid>.lock, a predictable name in a shared directory, and this open follows symlinks, so another local user can pre-create it as a symlink and redirect the flock.

os.O_NOFOLLOW closes it. The comment just above already notes the namespace is shared.

qemu-runner.sh and test-matrix.sh each spelled the ssh option list for
the test VM, and the copies had drifted: the runner kept the peer alive
for 10 s x 6 and the matrix for 15 s x 4. tests/lib/qemu-ssh.sh now
holds the one list. qemu_ssh_opts fills QEMU_SSH_OPTS at call time so a
caller can still wrap its ssh in timeout(1), which cannot wrap a shell
function. Both lanes keep the 60 s dead-peer budget.
The parser owns one capability-first command tree used by Make and CI.
Suite registration cannot add commands. run writes results.json before
the backend stops, so a failing teardown cannot lose a completed lane,
and records the argv it parsed. report is red for a red lane in every
format. Exit 77 is for a prerequisite reported absent before anything
starts; a backend that fails to start is red.

qemu-runner.sh stores VM state across start and stop commands. A
command that starts a VM owns its cleanup trap, preserving the trap
installed by test-matrix.sh. Start rejects an unusable guest /tmp, and
reports the serial console and qemu's own output before the run
directory holding them goes, since that text is all the caller sees of
a VM that did not boot. Stop verifies the recorded process still names
its pidfile before sending a signal. tests/test-qemu-runner.sh pins
both against stand-ins, so no VM boots: a recycled pid, a process
whose argv names the pidfile, and a start that never opens the port.
Every elfuse cleanup is an atexit hook, so a guest killed on timeout,
Ctrl-C, or a dead harness leaves its scratch, fork children, and SysV
objects behind, and the run leaves the QEMU VM with them. make clean
deletes build/conformance/qemu.state, the only record of that VM.

scripts/conformance clean sweeps in dependency order: the VM through
qemu-runner.sh stop, disowned VMs whose 9p share names this checkout,
the harness's detached guest session leaders and --fork-child orphans,
then the runtime scratch, and last a chmod u+rwx over the results tree
so make clean can remove the mode-0 directories gVisor leaves. Only
the exact scratch templates the runtime uses are swept, because review
checkouts share the elfuse- prefix.

SysV objects are reported, never removed. The guest key reaches the
host unchanged, so an elfuse object is indistinguishable from another
of the user's, and outliving its creator is ordinary SysV lifecycle.

It keeps the lock files, since unlinking a held lock lets two sessions
run; the results; another checkout's VMs and guests; and any elfuse
run from a terminal. While any elfuse of the user is alive both the
scratch sweep and the SysV report are skipped, since every elfuse
shares the /tmp names. A held lock makes it refuse and name the pid
holding it. --dry-run prefixes every action it would take with would.

make clean-conformance is the alias and joins the goals the build-flavor
guard skips.
clean sees only that a segment's creator is dead, which any long-lived
segment of the user's looks like, so it reports them all and removes
none. A lane knows more: run records the segments that appeared while it
held the backend, by id, key and creator pid, and clean removes those
and reports the rest.

Shared memory only. A segment's CPID is its creator and never changes,
while ipcs gives a queue its last sender and receiver.
docs/testing.md lists the public commands with their defaults and the
Make aliases. docs/conformance.md defines the result and extension
contracts and what clean-conformance sweeps, reports, and keeps.
@henrybear327

Copy link
Copy Markdown
Collaborator Author

Closing in favor of smaller PR.

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