ww test: fork-isolated record-and-continue harness (lib/test, both stages)
lib/test/run.ww: fork+wait4 runner; each @test runs in its own child, abort/SEGV/FPE decoded from wait-status, failures recorded and the run continues; exit = fail count. Tests are hermetic: module globals do not persist test-to-test (fresh fork image; sanctioned divergence from harec's shared-process __test_main, no setjmp/signal layer needed). -T synth (both stages) emits a module-global (str,*fn() void) table + return run(table) instead of straight-line calls. Driver twins bundle lib/test under test mode and gain ww test -c/-o (go test -c) so the byte-id gates diff the same artifact the real path builds. Gates 989/910/997 rewired onto it; new 911 pins record-and-continue across all three fault classes; 949 +3 rows. (#17-team commit-2)
This commit is contained in:
108
lib/test/run.ww
Normal file
108
lib/test/run.ww
Normal file
@@ -0,0 +1,108 @@
|
||||
package test;
|
||||
|
||||
// Plan-9-lean port of Hare's @test runner (ref/hare/test/+test.ha
|
||||
// __test_main:97-115, run_test:284, do_test:250). MECHANISM DIVERGENCE
|
||||
// (drew-17-attest-spec.md §a; rob ruling 2026-06-10, task #17): Hare
|
||||
// isolates each test in ONE process via arch::setjmp + an rt::onabort
|
||||
// hook + a SIGSEGV signal handler; ww has none of those primitives, so
|
||||
// we fork per test and read the child's wait-status. A clean child
|
||||
// exit(0) is a pass; abort/div0/SIGSEGV/nonzero-exit all surface as a
|
||||
// failing child status, so the fork boundary catches EVERY fault class
|
||||
// the setjmp+signal path catches, at a process boundary. The proven
|
||||
// fork+wait4+WEXITSTATUS decode is procrun (selfhost/cmd/ww/main.ww:
|
||||
// 154-177). CONSEQUENCE (sanctioned): module globals do NOT persist
|
||||
// test-to-test — each test runs in a fresh fork snapshot. Hare's tests
|
||||
// share one process, so its globals persist; ww's are hermetic.
|
||||
//
|
||||
// D1/D4/D5 reductions also retained from the -T synth era: no
|
||||
// __test_array linker section (the synth hands us a value table), no
|
||||
// sort, no file:line reflection. fnmatch name-filtering is task #17
|
||||
// commit-3 (drew spec §d), not here.
|
||||
|
||||
import os;
|
||||
|
||||
// run — execute each test in `tests` in a forked subprocess, report a
|
||||
// per-test status line, and return the failure count (the synthesized
|
||||
// `export fn main()` returns this, so the process exits nonzero iff any
|
||||
// test failed). The table is `[](str, *fn() void)` — Hare models a test
|
||||
// as `struct { name: str, func: *fn() void }` (+test.ha:23-26); the -T
|
||||
// synth emits the value-table tuple form (rob ruling), so the runner
|
||||
// reads `.0`/`.1` off each row rather than named fields.
|
||||
//
|
||||
// The private helpers carry a `tst` prefix: until real packages (#8) give
|
||||
// lib/test symbol isolation, every -T build flat-bundles this module into
|
||||
// the SAME bare-leaf scope as the test's own modules, so an un-prefixed
|
||||
// `puts`/`runone` collides with a same-named module-private fn (lib/dirs
|
||||
// and lib/temp both ship a private `puts(off, s)`). `run` stays the bound
|
||||
// runner name (the synth's callee). A user `fn run` (or `const __wwtests`)
|
||||
// in a @test unit collides with the synth: cstage — the shipping `ww test`
|
||||
// path — rejects it loudly ("duplicate fn run" / type mismatch, rc!=0).
|
||||
// wwstage TOLERATES the duplicate (#23, pre-existing rule-10 gap: cstage
|
||||
// rejects a duplicate fn, wwstage does not), so the collision is loud on
|
||||
// the reference stage but silent on wwstage until #23 lands.
|
||||
export fn run(tests: [](str, *fn() void)) i32 = {
|
||||
let nfail: i32 = 0;
|
||||
let i: i32 = 0;
|
||||
for (i < tests.len) {
|
||||
let row: (str, *fn() void) = tests[i];
|
||||
let name: str = row.0;
|
||||
let f: *fn() void = row.1;
|
||||
tstputs(name);
|
||||
tstputs(" ... ");
|
||||
let st: i32 = tstrunone(f);
|
||||
if (st == 0) {
|
||||
tstputs("ok\n");
|
||||
} else {
|
||||
tstputs("FAIL\n");
|
||||
nfail += 1;
|
||||
};
|
||||
i += 1;
|
||||
};
|
||||
tstputuint(tests.len - nfail);
|
||||
tstputs(" passed, ");
|
||||
tstputuint(nfail);
|
||||
tstputs(" failed\n");
|
||||
return nfail;
|
||||
};
|
||||
|
||||
// runone — fork, run `f` in the child, decode the child's wait-status.
|
||||
// Byte-for-byte the procrun (main.ww:154-177) status decode: low 7 bits
|
||||
// are the killing signal (abort=SIGABRT, div0/SIGSEGV), 0 if the child
|
||||
// exited; next byte is the exit code.
|
||||
fn tstrunone(f: *fn() void) i32 = {
|
||||
let pid: i32 = os.fork();
|
||||
if (pid < 0) { return -1; };
|
||||
if (pid == 0) {
|
||||
(*f)();
|
||||
os.exit(0i32);
|
||||
};
|
||||
let status: i32 = 0;
|
||||
let r: i32 = os.wait4(pid, &status, 0i32, nil: *void);
|
||||
if (r < 0) { return -1; };
|
||||
if ((status & 127i32) != 0) { return 1; };
|
||||
return (status >> 8i32) & 255i32;
|
||||
};
|
||||
|
||||
fn tstputs(s: str) void = {
|
||||
os.write(os.STDOUT_FILENO, s.ptr, s.len: u64);
|
||||
};
|
||||
|
||||
// tstputuint — write a non-negative i32 in decimal. Lean substitute for
|
||||
// fmt::printf (Hare's test uses fmt); keeping lib/test's bundle floor at
|
||||
// os-only avoids pulling the io/strconv stack into every -T build.
|
||||
fn tstputuint(n: i32) void = {
|
||||
let buf: [16]u8;
|
||||
let i: i32 = 16;
|
||||
let v: i32 = n;
|
||||
if (v == 0) {
|
||||
i -= 1;
|
||||
buf[i] = 48u8;
|
||||
} else {
|
||||
for (v > 0) {
|
||||
i -= 1;
|
||||
buf[i] = (48 + v % 10): u8;
|
||||
v = v / 10;
|
||||
};
|
||||
};
|
||||
os.write(os.STDOUT_FILENO, &buf[i], (16 - i): u64);
|
||||
};
|
||||
Reference in New Issue
Block a user