From 62c7771594a47adaab93338b97abae8d1825a34b Mon Sep 17 00:00:00 2001 From: Hojun-Cho Date: Tue, 16 Jun 2026 14:54:19 +0900 Subject: [PATCH] wcc/ww: self-hosted deterministic ar-writer + archive dup-detect (M3-tail c5a, #62) Per-package .a archives are written by a self-hosted deterministic ar writer (zeroed mtime/uid/gid, fixed mode 100644, stable member order) so cstage and wwstage emit byte-identical archives. The linker force-loads the root .o positionally and pulls deps from .a; a post-pull PASS-3 over unloaded members reports duplicate symbols through the archive (#31). Both stages mirrored (cmd/ + selfhost/). USER-ruled D2 (self-hosted ar writer); pike P1/P2 link model. Gate 989_separchive_run proves cs.a==ww.a byte-identity, 3x-determinism, link-consumes-.a (exit 7), and the masked-dup-through-.a loud fire. --- Makefile | 11 + cmd/w6l/obj.c | 21 ++ cmd/ww/main.c | 94 ++++++++- selfhost/cmd/w6l/main.combined.ww | 27 +++ selfhost/cmd/w6l/obj.ww | 27 +++ selfhost/cmd/ww/main.combined.ww | 104 +++++++++- selfhost/cmd/ww/main.ww | 104 +++++++++- test/wcc/989_separchive_run.c | 325 ++++++++++++++++++++++++++++++ 8 files changed, 689 insertions(+), 24 deletions(-) create mode 100644 test/wcc/989_separchive_run.c diff --git a/Makefile b/Makefile index 6523c24e..d6d4fcd8 100644 --- a/Makefile +++ b/Makefile @@ -565,6 +565,7 @@ TESTS = $(BIN)/test_smoke $(BIN)/test_lex $(BIN)/test_parse $(BIN)/test_check \ $(BIN)/test_m3sep_run \ $(BIN)/test_sepbuild_run \ $(BIN)/test_sepcycle_dup \ + $(BIN)/test_separchive_run \ $(BIN)/test_floatlit_run \ $(BIN)/test_checked_run \ $(BIN)/test_floatarr_run \ @@ -3118,6 +3119,16 @@ $(BIN)/test_sepcycle_dup: test/wcc/989_sepcycle_dup.c $(BIN)/ww $(BIN)/ww_ww \ $(LIB)/libwwrt.a | $(BIN) $(CC) $(CFLAGS) -o $@ $< +# 989_separchive_run — M3-tail commit-5a gate (#46, #62): per-package `.a` +# substrate (root=.o force-load, deps=.a) + the archive-path #31 dup-detect +# (PASS 3). Asserts build+run, `.a` determinism (3x), cs.a==ww.a byte-id, +# and the masked-dup reject THROUGH the `.a` path (both stages, achievable +# parity). Needs both driver + both compiler + both linker stages + libwwrt. +$(BIN)/test_separchive_run: test/wcc/989_separchive_run.c $(BIN)/ww $(BIN)/ww_ww \ + $(BIN)/w6c $(BIN)/w6c_ww $(BIN)/w6a $(BIN)/w6a_ww \ + $(BIN)/w6l $(BIN)/w6l_ww $(LIB)/libwwrt.a | $(BIN) + $(CC) $(CFLAGS) -o $@ $< + $(BIN)/test_floatlit_run: test/wcc/989_floatlit_run.c $(BIN)/ww $(BIN)/w6c \ $(BIN)/w6c_ww $(BIN)/w6a $(BIN)/w6l $(LIB)/libwwrt.a | $(BIN) $(CC) $(CFLAGS) -o $@ $< diff --git a/cmd/w6l/obj.c b/cmd/w6l/obj.c index 928faf79..389a068a 100644 --- a/cmd/w6l/obj.c +++ b/cmd/w6l/obj.c @@ -227,6 +227,27 @@ load_archive(Lnk *l, const char *path, u8 *buf, u64 len) } } + /* Pass 3 (#31, task #62): catch a dup the selective pull MASKED. + * A member that pass 2 left unloaded yet defines a name some other + * object already `defined` is exactly the cross-package collision + * archive selective-pull would silently skip (obj.c:218). Lookup + * (never intern) so the pull predicate / selective-pull is wholly + * untouched → byte-id-neutral on every clean link. Pulled-member + * and direct-`.o` dups stay caught at load by the defined-check + * below in load_image. ww has no weak symbols → a genuine dup. */ + for (ArMember *m = head; m; m = m->next) { + if (m->loaded || m->defs == NULL) continue; + for (int i = 0; m->defs[i]; i++) { + Lsym *s = l_lookup(l, m->defs[i]); + if (s != NULL && s->defined) { + fprintf(stderr, + "w6l: %s: duplicate symbol %s\n", + path, m->defs[i]); + l->errs++; + } + } + } + /* Free unloaded members; loaded ones had their bytes consumed * by load_image (which took the copy). */ while (head) { diff --git a/cmd/ww/main.c b/cmd/ww/main.c index 70713b2e..9784014b 100644 --- a/cmd/ww/main.c +++ b/cmd/ww/main.c @@ -885,11 +885,68 @@ sep_compose_unit(struct sepgraph *g, int pi, const char *scratch, return 0; } +/* archive_o — write a deterministic single-member SysV ar archive at + * `apath` wrapping the object at `objpath`. No armap / long-name table: + * w6l reads each member's ELF .symtab directly (obj.c elf_globals) and + * skips '/'-named members, so a package `.a` needs only the global magic, + * one 60-byte member header, and the `.o` bytes (newline-padded to even). + * Zeroed mtime/uid/gid + fixed mode + a fixed member name make the bytes + * a pure function of the `.o` content → cstage `.a` == wwstage `.a` + * (rule 10) and a stable md5 for the 5b cache key. The wwstage twin is + * archiveo (selfhost/cmd/ww/main.ww). */ +static int +archive_o(const char *objpath, const char *apath) +{ + FILE *in = fopen(objpath, "rb"); + if (in == NULL) { + fprintf(stderr, "ww --sep: cannot read %s\n", objpath); + return -1; + } + fseek(in, 0, SEEK_END); + long n = ftell(in); + fseek(in, 0, SEEK_SET); + if (n < 0) { fclose(in); return -1; } + unsigned char *buf = malloc((size_t)n); + if (buf == NULL) { fclose(in); return -1; } + if (fread(buf, 1, (size_t)n, in) != (size_t)n) { + free(buf); fclose(in); return -1; + } + fclose(in); + + FILE *out = fopen(apath, "wb"); + if (out == NULL) { + fprintf(stderr, "ww --sep: cannot open %s\n", apath); + free(buf); + return -1; + } + fwrite("!\n", 1, 8, out); + /* sizelint-ok: the 60-byte ar(5) member header and its field offsets + * are a FILE-FORMAT constant, not a type size (CLAUDE.md rule 13). */ + char hdr[60]; + memset(hdr, ' ', sizeof hdr); + memcpy(hdr + 0, "pkg.o/", 6); /* GNU short-name '/' terminator */ + hdr[16] = '0'; /* mtime (zeroed → determinism) */ + hdr[28] = '0'; /* uid (zeroed) */ + hdr[34] = '0'; /* gid (zeroed) */ + memcpy(hdr + 40, "100644", 6); /* mode (fixed octal) */ + char sz[12]; + int szn = snprintf(sz, sizeof sz, "%lu", (unsigned long)n); + memcpy(hdr + 48, sz, (size_t)szn); + hdr[58] = 0x60; /* member-header magic byte */ + hdr[59] = 0x0a; + fwrite(hdr, 1, sizeof hdr, out); + fwrite(buf, 1, (size_t)n, out); + if (n & 1) fputc('\n', out); /* members are 2-byte aligned */ + fclose(out); + free(buf); + return 0; +} + /* build_one_sep — the --sep orchestration: discover_deps, reverse_topo, - * the transitive producer loop (one `w6c -c -I` per package, dep-first), - * then a flat `w6l` of the `.o` set (per-pkg `.a` + multi-archive link - * is commit 4). Side files land in a cold `.sepwork` scratch dir - * (the structured cache is commit 5). */ + * the transitive producer loop (one `w6c -c -I` per package, dep-first, + * each DEP `.o` wrapped in its own deterministic `.a`), then a + * reverse-topo `w6l` of the root `.o` + dep `.a` set + libwwrt.a. Side + * files land in a cold `.sepwork` dir (content-keyed cache = 5b). */ static int build_one_sep(const char *src, int entry_is_dir, const char *out, const char *objstem, const char *extra_includes, const char *extra_libs, @@ -992,11 +1049,26 @@ build_one_sep(const char *src, int entry_is_dir, const char *out, g->pkg[pi].path[0] ? g->pkg[pi].path : "(root)"); free(order); free(g); return 1; } + /* wrap each DEP package's `.o` in its own deterministic `.a` + * (5a). The ROOT stays a positional `.o` (force-loaded — it's + * the build target, always fully linked), so `main` is defined + * before any archive is processed, matching build_one's root + * treatment. The link consumes `.o`/`.a`, never `.wwi`. */ + if (pi != root) { + char apath[1024]; + sep_fname(g, pi, scratch, ".a", apath, sizeof apath); + if (archive_o(obj, apath) != 0) { + fprintf(stderr, "ww --sep: archive failed for %s\n", + g->pkg[pi].path[0] ? g->pkg[pi].path : "(root)"); + free(order); free(g); return 1; + } + } } - /* flat link: root.o first (order[norder-1]), deps after; runtime - * archive selectively pulls only undefined runtime symbols (commit 4 - * adds per-pkg `.a` + multi-archive reverse-topo link). */ + /* reverse-topo link: root `.o` first (order[norder-1], force-loaded), + * then transitive dep `.a` in reverse-topo order, then libwwrt.a — + * each archive selectively pulls only members satisfying a live + * undef. */ char rtargs[2048] = {0}; char rtpath[1024]; snprintf(rtpath, sizeof rtpath, "%s/libwwrt.a", libdir); @@ -1010,10 +1082,12 @@ build_one_sep(const char *src, int entry_is_dir, const char *out, } char objs[8192] = {0}; for (int oi = norder - 1; oi >= 0; oi--) { - char obj[1024]; - sep_fname(g, order[oi], scratch, ".o", obj, sizeof obj); + char path[1024]; + /* root: positional `.o` (force-load); deps: `.a` (selective). */ + sep_fname(g, order[oi], scratch, + order[oi] == root ? ".o" : ".a", path, sizeof path); size_t n = strlen(objs); - snprintf(objs + n, sizeof objs - n, "%s%s", n ? " " : "", obj); + snprintf(objs + n, sizeof objs - n, "%s%s", n ? " " : "", path); } const char *libargs = (extra_libs && extra_libs[0]) ? extra_libs : ""; const char *libdirset = (extra_libdirs && extra_libdirs[0]) ? extra_libdirs : ""; diff --git a/selfhost/cmd/w6l/main.combined.ww b/selfhost/cmd/w6l/main.combined.ww index 3a948c29..3cee9fde 100644 --- a/selfhost/cmd/w6l/main.combined.ww +++ b/selfhost/cmd/w6l/main.combined.ww @@ -3352,6 +3352,33 @@ fn loadarchive(l: *lnk, path: *u8, buf: *u8, len: u64) i32 = { m = m.mnext; }; }; + + // Pass 3 (#31, task #62): catch a dup the selective pull MASKED. A + // member pass 2 left unloaded yet defining a name some other object + // already `defined` is exactly the cross-package collision archive + // selective-pull would silently skip. lookup (never intern) keeps + // the pull machinery untouched → byte-id-neutral on every clean + // link. Pulled-member and direct-`.o` dups stay caught in loadimage. + // ww has no weak symbols → a genuine dup. (The bare message matches + // loadimage's; the w6l path+name text divergence is #61, separate.) + let dm: *armember = head; + for (dm != nil) { + if (dm.loaded == 0) { + let de: *defent = dm.defs; + for (de != nil) { + let s: *lsym = lookup(l, de.name); + if (s != nil) { + if (s.defined != 0) { + let msg: str = "w6l: duplicate symbol\n"; + os.write(2, msg.ptr, msg.len: u64); + l.errs += 1; + }; + }; + de = de.dnext; + }; + }; + dm = dm.mnext; + }; return 0; }; diff --git a/selfhost/cmd/w6l/obj.ww b/selfhost/cmd/w6l/obj.ww index 366b5e4f..9b5740c3 100644 --- a/selfhost/cmd/w6l/obj.ww +++ b/selfhost/cmd/w6l/obj.ww @@ -376,6 +376,33 @@ fn loadarchive(l: *lnk, path: *u8, buf: *u8, len: u64) i32 = { m = m.mnext; }; }; + + // Pass 3 (#31, task #62): catch a dup the selective pull MASKED. A + // member pass 2 left unloaded yet defining a name some other object + // already `defined` is exactly the cross-package collision archive + // selective-pull would silently skip. lookup (never intern) keeps + // the pull machinery untouched → byte-id-neutral on every clean + // link. Pulled-member and direct-`.o` dups stay caught in loadimage. + // ww has no weak symbols → a genuine dup. (The bare message matches + // loadimage's; the w6l path+name text divergence is #61, separate.) + let dm: *armember = head; + for (dm != nil) { + if (dm.loaded == 0) { + let de: *defent = dm.defs; + for (de != nil) { + let s: *lsym = lookup(l, de.name); + if (s != nil) { + if (s.defined != 0) { + let msg: str = "w6l: duplicate symbol\n"; + os.write(2, msg.ptr, msg.len: u64); + l.errs += 1; + }; + }; + de = de.dnext; + }; + }; + dm = dm.mnext; + }; return 0; }; diff --git a/selfhost/cmd/ww/main.combined.ww b/selfhost/cmd/ww/main.combined.ww index 9b72d0d7..960cd6d5 100644 --- a/selfhost/cmd/ww/main.combined.ww +++ b/selfhost/cmd/ww/main.combined.ww @@ -4078,10 +4078,83 @@ fn sepcomposeunit(g: *sepgraph, pi: i32, scratch: *u8, order: []i32, return 0; }; +// archiveo — twin of cmd/ww/main.c archive_o. Writes a deterministic +// single-member SysV ar archive at `apath` wrapping the `.o` at +// `objpath`. No armap / long-name table: w6l reads each member's ELF +// .symtab directly and skips '/'-named members, so a package `.a` is +// just the global magic + one 60-byte member header + the `.o` bytes +// (newline-padded to even). Zeroed mtime/uid/gid + fixed mode + a fixed +// member name make the bytes a pure function of the `.o` content → +// cstage `.a` == wwstage `.a` (rule 10) and a stable md5 for the 5b +// cache key. +fn archiveo(objpath: *u8, apath: *u8) i32 = { + let objp: *u8; + let objn: u64; + objp, objn = slurp(objpath); + if (objp == nil) { + cerr("ww --sep: cannot read object for archive\n"); + return -1; + }; + let pad: u64 = 0u64; + if ((objn & 1u64) != 0u64) { pad = 1u64; }; + // sizelint-ok: 8B ar(5) magic + 60B member header are FILE-FORMAT + // constants, not type sizes (CLAUDE.md rule 13 carve-out). + let total: u64 = 8u64 + 60u64 + objn + pad; + let outs: []u8 = alloc([], total)!; + let out: *u8 = outs.ptr; + + // 60-byte member header at offset 8, ASCII space-filled, fields + // left-justified; the 8-byte global magic precedes it. strinto + // copies a str's bytes (the working i32-index idiom) — a direct + // `out[i] = lit[i: i32]` store trips the cgen's str-index-rvalue arm. + let h: u64 = 8u64; + let j: u64 = 0u64; + for (j < 60u64) { out[h + j] = 32u8; j += 1u64; }; // 0x20 fill + strinto(out, 0u64, "!\n"); // global magic + strinto(out, h, "pkg.o/"); // name (GNU '/' terminator) + out[h + 16u64] = 48u8; // mtime "0" (zeroed → determinism) + out[h + 28u64] = 48u8; // uid "0" + out[h + 34u64] = 48u8; // gid "0" + strinto(out, h + 40u64, "100644"); // mode (fixed octal) + // size: decimal byte-count of the .o, left-justified at [48..58) + if (objn == 0u64) { + out[h + 48u64] = 48u8; + } else { + let ndig: u64 = 0u64; + let t: u64 = objn; + for (t > 0u64) { ndig += 1u64; t = t / 10u64; }; + let d: u64 = ndig; + t = objn; + for (t > 0u64) { + d -= 1u64; + out[h + 48u64 + d] = ((t % 10u64): u8) + 48u8; + t = t / 10u64; + }; + }; + out[h + 58u64] = 96u8; // member-header magic 0x60 + out[h + 59u64] = 10u8; // 0x0a + + // the .o bytes, then a '\n' pad iff the size is odd (2-byte align). + let k: u64 = 0u64; + for (k < objn) { out[h + 60u64 + k] = objp[k]; k += 1u64; }; + if (pad != 0u64) { out[h + 60u64 + objn] = 10u8; }; + + let fd: i32 = os.open(pathstr(apath), + os.flag.WRONLY | os.flag.CREATE | os.flag.TRUNC, 420i32); // 0o644 + if (fd < 0) { + cerr("ww --sep: cannot open archive\n"); + return -1; + }; + os.writeall(fd, out, total); + os.close(fd); + return 0; +}; + // buildonesep — the --sep orchestration: discover deps, reverse-topo, -// the transitive producer loop (one `w6c -c -I` per package, dep-first), -// then a flat `w6l` of the `.o` set. Side files land in a cold -// `.sepwork` scratch dir. Twin of cstage build_one_sep. +// the transitive producer loop (one `w6c -c -I` per package, dep-first, +// each `.o` wrapped in its own deterministic per-package `.a`), then a +// reverse-topo `w6l` of the `.a` set + libwwrt.a. Side files land in a +// cold `.sepwork` scratch dir. Twin of cstage build_one_sep. fn buildonesep(selfdir: *u8, src: *u8, entryisdir: i32, out: *u8, objstem: *u8, incs: *u8, lf: *lflags) i32 = { let c6: *u8 = joinpathlit(selfdir, "w6c_ww"); @@ -4231,12 +4304,26 @@ fn buildonesep(selfdir: *u8, src: *u8, entryisdir: i32, out: *u8, return 1; }; }; + // Wrap each DEP package's `.o` in its own deterministic `.a` + // (5a). The ROOT stays a positional `.o` (force-loaded — it's + // the build target), so `main` is defined before any archive is + // processed (mirrors buildone's root treatment). The link + // consumes `.o`/`.a`, never `.wwi`. + if (pi != root) { + let apath: *u8 = sepfname(g, pi, scratch, ".a"); + if (archiveo(objf, apath) != 0) { + cerr("ww --sep: archive failed\n"); + return 1; + }; + }; oi += 1; }; - // Flat link: root.o first (order[norder-1]), deps after; libwwrt.a - // selectively pulls runtime symbols. argv: 4 fixed (w6l,-o,out) + 1 - // per .o + 1 libwwrt + 2*nlibdirs + 2*nlibs + 1 nil. + // Reverse-topo link of the per-package `.a` set: root.a first + // (order[norder-1]), deps after, then libwwrt.a (which still + // selectively pulls only the runtime members a live undef needs). + // argv: 3 fixed (w6l,-o,out) + 1 per .a + 1 libwwrt + 2*nlibdirs + // + 2*nlibs + 1 nil. let nldirs: i32 = 0; let nllibs: i32 = 0; let ldirs: **u8 = nil; @@ -4256,7 +4343,10 @@ fn buildonesep(selfdir: *u8, src: *u8, entryisdir: i32, out: *u8, let pos: i32 = 3; let li: i32 = norder - 1; for (li >= 0) { - largv[pos] = sepfname(g, order[li], scratch, ".o"); + // root: positional `.o` (force-load); deps: `.a` (selective). + let suf: str = ".a"; + if (order[li] == root) { suf = ".o"; }; + largv[pos] = sepfname(g, order[li], scratch, suf); pos += 1; li -= 1; }; diff --git a/selfhost/cmd/ww/main.ww b/selfhost/cmd/ww/main.ww index cc7529de..df4840be 100644 --- a/selfhost/cmd/ww/main.ww +++ b/selfhost/cmd/ww/main.ww @@ -1204,10 +1204,83 @@ fn sepcomposeunit(g: *sepgraph, pi: i32, scratch: *u8, order: []i32, return 0; }; +// archiveo — twin of cmd/ww/main.c archive_o. Writes a deterministic +// single-member SysV ar archive at `apath` wrapping the `.o` at +// `objpath`. No armap / long-name table: w6l reads each member's ELF +// .symtab directly and skips '/'-named members, so a package `.a` is +// just the global magic + one 60-byte member header + the `.o` bytes +// (newline-padded to even). Zeroed mtime/uid/gid + fixed mode + a fixed +// member name make the bytes a pure function of the `.o` content → +// cstage `.a` == wwstage `.a` (rule 10) and a stable md5 for the 5b +// cache key. +fn archiveo(objpath: *u8, apath: *u8) i32 = { + let objp: *u8; + let objn: u64; + objp, objn = slurp(objpath); + if (objp == nil) { + cerr("ww --sep: cannot read object for archive\n"); + return -1; + }; + let pad: u64 = 0u64; + if ((objn & 1u64) != 0u64) { pad = 1u64; }; + // sizelint-ok: 8B ar(5) magic + 60B member header are FILE-FORMAT + // constants, not type sizes (CLAUDE.md rule 13 carve-out). + let total: u64 = 8u64 + 60u64 + objn + pad; + let outs: []u8 = alloc([], total)!; + let out: *u8 = outs.ptr; + + // 60-byte member header at offset 8, ASCII space-filled, fields + // left-justified; the 8-byte global magic precedes it. strinto + // copies a str's bytes (the working i32-index idiom) — a direct + // `out[i] = lit[i: i32]` store trips the cgen's str-index-rvalue arm. + let h: u64 = 8u64; + let j: u64 = 0u64; + for (j < 60u64) { out[h + j] = 32u8; j += 1u64; }; // 0x20 fill + strinto(out, 0u64, "!\n"); // global magic + strinto(out, h, "pkg.o/"); // name (GNU '/' terminator) + out[h + 16u64] = 48u8; // mtime "0" (zeroed → determinism) + out[h + 28u64] = 48u8; // uid "0" + out[h + 34u64] = 48u8; // gid "0" + strinto(out, h + 40u64, "100644"); // mode (fixed octal) + // size: decimal byte-count of the .o, left-justified at [48..58) + if (objn == 0u64) { + out[h + 48u64] = 48u8; + } else { + let ndig: u64 = 0u64; + let t: u64 = objn; + for (t > 0u64) { ndig += 1u64; t = t / 10u64; }; + let d: u64 = ndig; + t = objn; + for (t > 0u64) { + d -= 1u64; + out[h + 48u64 + d] = ((t % 10u64): u8) + 48u8; + t = t / 10u64; + }; + }; + out[h + 58u64] = 96u8; // member-header magic 0x60 + out[h + 59u64] = 10u8; // 0x0a + + // the .o bytes, then a '\n' pad iff the size is odd (2-byte align). + let k: u64 = 0u64; + for (k < objn) { out[h + 60u64 + k] = objp[k]; k += 1u64; }; + if (pad != 0u64) { out[h + 60u64 + objn] = 10u8; }; + + let fd: i32 = os.open(pathstr(apath), + os.flag.WRONLY | os.flag.CREATE | os.flag.TRUNC, 420i32); // 0o644 + if (fd < 0) { + cerr("ww --sep: cannot open archive\n"); + return -1; + }; + os.writeall(fd, out, total); + os.close(fd); + return 0; +}; + // buildonesep — the --sep orchestration: discover deps, reverse-topo, -// the transitive producer loop (one `w6c -c -I` per package, dep-first), -// then a flat `w6l` of the `.o` set. Side files land in a cold -// `.sepwork` scratch dir. Twin of cstage build_one_sep. +// the transitive producer loop (one `w6c -c -I` per package, dep-first, +// each `.o` wrapped in its own deterministic per-package `.a`), then a +// reverse-topo `w6l` of the `.a` set + libwwrt.a. Side files land in a +// cold `.sepwork` scratch dir. Twin of cstage build_one_sep. fn buildonesep(selfdir: *u8, src: *u8, entryisdir: i32, out: *u8, objstem: *u8, incs: *u8, lf: *lflags) i32 = { let c6: *u8 = joinpathlit(selfdir, "w6c_ww"); @@ -1357,12 +1430,26 @@ fn buildonesep(selfdir: *u8, src: *u8, entryisdir: i32, out: *u8, return 1; }; }; + // Wrap each DEP package's `.o` in its own deterministic `.a` + // (5a). The ROOT stays a positional `.o` (force-loaded — it's + // the build target), so `main` is defined before any archive is + // processed (mirrors buildone's root treatment). The link + // consumes `.o`/`.a`, never `.wwi`. + if (pi != root) { + let apath: *u8 = sepfname(g, pi, scratch, ".a"); + if (archiveo(objf, apath) != 0) { + cerr("ww --sep: archive failed\n"); + return 1; + }; + }; oi += 1; }; - // Flat link: root.o first (order[norder-1]), deps after; libwwrt.a - // selectively pulls runtime symbols. argv: 4 fixed (w6l,-o,out) + 1 - // per .o + 1 libwwrt + 2*nlibdirs + 2*nlibs + 1 nil. + // Reverse-topo link of the per-package `.a` set: root.a first + // (order[norder-1]), deps after, then libwwrt.a (which still + // selectively pulls only the runtime members a live undef needs). + // argv: 3 fixed (w6l,-o,out) + 1 per .a + 1 libwwrt + 2*nlibdirs + // + 2*nlibs + 1 nil. let nldirs: i32 = 0; let nllibs: i32 = 0; let ldirs: **u8 = nil; @@ -1382,7 +1469,10 @@ fn buildonesep(selfdir: *u8, src: *u8, entryisdir: i32, out: *u8, let pos: i32 = 3; let li: i32 = norder - 1; for (li >= 0) { - largv[pos] = sepfname(g, order[li], scratch, ".o"); + // root: positional `.o` (force-load); deps: `.a` (selective). + let suf: str = ".a"; + if (order[li] == root) { suf = ".o"; }; + largv[pos] = sepfname(g, order[li], scratch, suf); pos += 1; li -= 1; }; diff --git a/test/wcc/989_separchive_run.c b/test/wcc/989_separchive_run.c new file mode 100644 index 00000000..04da9efc --- /dev/null +++ b/test/wcc/989_separchive_run.c @@ -0,0 +1,325 @@ +/* + * 989_separchive_run — M3-tail commit-5a gate (#46, task #62): the + * per-package `.a` substrate + its archive-path #31 dup-detect, both + * stages, COLD. Commit 5a makes `ww build --sep` wrap each DEP package's + * `.o` in a deterministic single-member `.a` (cstage archive_o / + * wwstage archiveo) and link the ROOT as a positional `.o` (force-loaded) + * + dep `.a` reverse-topo + libwwrt.a. The #31 dup the selective pull + * would mask is caught by a post-pull PASS 3 in w6l/w6l_ww load_archive. + * + * Legs (all COLD — `.sepwork` is wiped each run): + * 1. POSITIVE: root→helper builds + runs exit 7, BOTH stages, through + * the `.a` link path (root `.o` + helper `.a`). + * 2. ★ DETERMINISM (ken, load-bearing): re-archive the same package 3× + * → byte-identical `.a` (proves zeroed mtime/uid/gid + fixed mode + + * fixed member name; a floating md5 would poison the 5b cache key). + * 3. ★ cs `.a` == ww `.a` (rule 10, the NEW byte-id substrate ken binds): + * the dep `.a` from `ww --sep` is byte-identical to the one from + * `ww_ww --sep`. + * 4. ★ #31 dup THROUGH the `.a` path (ken #263 + D3, the leg that proves + * PASS 3 closed the selective-pull hole): two DEP packages each export + * the same link symbol via `@symbol("dup_sym")`, referenced by root. + * One dep's member is pulled; the OTHER lands UNPULLED and defines an + * already-`defined` name — exactly what selective-pull skips. BOTH + * stages must (exit≠0) ∧ (stderr contains "duplicate symbol") ∧ (no + * partial binary). NON-VACUITY flip: give the second dep a DISTINCT + * symbol → both stages build + run (exit 3), proving the leg actually + * discriminates (without PASS 3 the dup leg would WRONGLY pass green). + * 5. ACHIEVABLE-parity posture (same as c4's 989_sepcycle_dup, because + * #61 is a separate commit): cs vs ww is `both exit≠0 ∧ both stderrs + * contain "duplicate symbol"` — NOT exact-stderr-equal (cstage names + * `: duplicate symbol `, wwstage the bare form). + * + * Light wwstage-driver test (CLAUDE.md rule 14): all fixtures + scratch + * live under /tmp, COLD each run. Models 989_sepbuild_run.c conventions. + */ +#include +#include +#include +#include +#include +#include + +#define EXPECT_EXIT 7 + +static int +runwait(const char *cmd) +{ + int rc = system(cmd); + if (rc == -1) return -1; + if (WIFEXITED(rc)) return WEXITSTATUS(rc); + return 1; +} + +static const char * +absbin(void) +{ + const char *b = getenv("BIN"); + if (!b) b = "out/bin"; + if (b[0] == '/') return b; + static char buf[2048]; + char cwd[1024]; + if (getcwd(cwd, sizeof cwd) == NULL) return NULL; + snprintf(buf, sizeof buf, "%s/%s", cwd, b); + return buf; +} + +static int +slurp(const char *path, char **outbuf, size_t *outlen) +{ + FILE *f = fopen(path, "rb"); + if (!f) return -1; + fseek(f, 0, SEEK_END); + long n = ftell(f); + fseek(f, 0, SEEK_SET); + if (n < 0) { fclose(f); return -1; } + char *b = malloc((size_t)n + 1); + if (!b) { fclose(f); return -1; } + if (fread(b, 1, (size_t)n, f) != (size_t)n) { free(b); fclose(f); return -1; } + b[n] = '\0'; + fclose(f); + *outbuf = b; + *outlen = (size_t)n; + return 0; +} + +static int +files_eq(const char *a, const char *b) +{ + char *ba = NULL, *bb = NULL; + size_t na = 0, nb = 0; + if (slurp(a, &ba, &na) < 0 || slurp(b, &bb, &nb) < 0) { + free(ba); free(bb); + return -1; + } + int eq = (na == nb && memcmp(ba, bb, na) == 0); + free(ba); free(bb); + return eq ? 0 : 1; +} + +static int +file_has(const char *path, const char *needle) +{ + char *b = NULL; + size_t n = 0; + if (slurp(path, &b, &n) < 0) return 0; + int found = (strstr(b, needle) != NULL); + free(b); + return found; +} + +static int +write_file(const char *path, const char *body) +{ + FILE *f = fopen(path, "wb"); + if (!f) return -1; + fputs(body, f); + fclose(f); + return 0; +} + +int +main(void) +{ + const char *bin = absbin(); + if (!bin) return 1; + char td[64], cmd[8192]; + int fail = 0; + + snprintf(td, sizeof td, "/tmp/wwar_%d", getpid()); + snprintf(cmd, sizeof cmd, "rm -rf %s", td); + runwait(cmd); + mkdir(td, 0755); + + /* ---- root → helper fixture (one dep `.a`) ------------------------ */ + char helpdir[1024], helpww[1100], rootww[1100]; + snprintf(helpdir, sizeof helpdir, "%s/helper", td); + mkdir(helpdir, 0755); + snprintf(helpww, sizeof helpww, "%s/h.ww", helpdir); + snprintf(rootww, sizeof rootww, "%s/root.ww", td); + if (write_file(helpww, + "package helper;\n" + "export fn val() i32 = { return 7; };\n") || + write_file(rootww, + "package main;\n" + "import helper;\n" + "fn main() i32 = { return helper.val(); };\n")) { + fail++; goto out; + } + + struct { const char *drv, *tag; char prog[1024]; } + stg[] = { { "ww", "cs", {0} }, { "ww_ww", "ww", {0} } }; + + /* Leg 1: POSITIVE build + run through the `.a` path, both stages. */ + for (int s = 0; s < 2; s++) { + snprintf(stg[s].prog, sizeof stg[s].prog, "%s/prog.%s", td, stg[s].tag); + snprintf(cmd, sizeof cmd, + "timeout 240 %s/%s build --sep -I %s -o %s %s >/dev/null 2>&1", + bin, stg[s].drv, td, stg[s].prog, rootww); + if (runwait(cmd) != 0) { + fprintf(stderr, "separchive FAIL: %s build --sep\n", stg[s].drv); + fail++; + continue; + } + int rc = runwait(stg[s].prog); + if (rc != EXPECT_EXIT) { + fprintf(stderr, "separchive FAIL: %s prog exit=%d expected %d\n", + stg[s].drv, rc, EXPECT_EXIT); + fail++; + } + } + + /* Root is a positional `.o` (force-loaded), so NO __root.a exists; + * the dep is the only `.a`. Assert the layout the driver produced. */ + { + char roota[1100], rooto[1100], helpa[1100]; + snprintf(roota, sizeof roota, "%s/prog.cs.sepwork/__root.a", td); + snprintf(rooto, sizeof rooto, "%s/prog.cs.sepwork/__root.o", td); + snprintf(helpa, sizeof helpa, "%s/prog.cs.sepwork/helper.a", td); + if (access(roota, 0) == 0) { + fprintf(stderr, "separchive FAIL: root wrapped in .a " + "(should stay positional .o)\n"); + fail++; + } + if (access(rooto, 0) != 0) { + fprintf(stderr, "separchive FAIL: missing root .o\n"); + fail++; + } + if (access(helpa, 0) != 0) { + fprintf(stderr, "separchive FAIL: missing dep helper.a\n"); + fail++; + } + } + + /* Leg 3: cs `.a` == ww `.a` (rule 10, the new byte-id substrate). */ + { + char a[1100], b[1100]; + snprintf(a, sizeof a, "%s/prog.cs.sepwork/helper.a", td); + snprintf(b, sizeof b, "%s/prog.ww.sepwork/helper.a", td); + if (files_eq(a, b) != 0) { + fprintf(stderr, "separchive FAIL: cs helper.a != ww helper.a " + "(rule 10 .a byte-id)\n"); + fail++; + } + } + + /* Leg 2: DETERMINISM — re-archive the same package 3× (cold each), + * the `.a` must be byte-identical (zeroed mtime/uid/gid, fixed mode, + * fixed member name). Copy each build's helper.a aside, compare. */ + { + char det[3][1100]; + int ok = 1; + for (int i = 0; i < 3; i++) { + char prog[1100]; + snprintf(prog, sizeof prog, "%s/det%d", td, i); + snprintf(cmd, sizeof cmd, + "timeout 240 %s/ww build --sep -I %s -o %s %s >/dev/null 2>&1", + bin, td, prog, rootww); + if (runwait(cmd) != 0) { ok = 0; break; } + snprintf(det[i], sizeof det[i], "%s/det%d.sepwork/helper.a", td, i); + } + if (!ok) { + fprintf(stderr, "separchive FAIL: determinism build\n"); + fail++; + } else if (files_eq(det[0], det[1]) != 0 || + files_eq(det[1], det[2]) != 0) { + fprintf(stderr, "separchive FAIL: .a not deterministic across " + "3 builds (floating md5 → poisons 5b cache)\n"); + fail++; + } + } + + /* ---- Leg 4: #31 dup THROUGH the `.a` path (PASS 3) --------------- */ + /* Two DEP packages each export the SAME link symbol via @symbol; + * root references both. One member is pulled, the other lands + * UNPULLED defining an already-`defined` name → the masked dup PASS 3 + * catches (#53 path-qualifies normal exports, so @symbol is the + * cleanest forced cross-package clash). */ + char pkgad[1024], pkgbd[1024], paww[1100], pbww[1100], droot[1100]; + snprintf(pkgad, sizeof pkgad, "%s/pkga", td); + snprintf(pkgbd, sizeof pkgbd, "%s/pkgb", td); + mkdir(pkgad, 0755); mkdir(pkgbd, 0755); + snprintf(paww, sizeof paww, "%s/a.ww", pkgad); + snprintf(pbww, sizeof pbww, "%s/b.ww", pkgbd); + snprintf(droot, sizeof droot, "%s/droot.ww", td); + if (write_file(paww, + "package pkga;\n" + "@symbol(\"dup_sym\") export fn afn() i32 = { return 1; };\n") || + write_file(pbww, + "package pkgb;\n" + "@symbol(\"dup_sym\") export fn bfn() i32 = { return 2; };\n") || + write_file(droot, + "package main;\n" + "import pkga;\n" + "import pkgb;\n" + "fn main() i32 = { return pkga.afn() + pkgb.bfn(); };\n")) { + fail++; goto out; + } + + for (int s = 0; s < 2; s++) { + char prog[1100], errf[1100]; + snprintf(prog, sizeof prog, "%s/dup.%s", td, stg[s].tag); + snprintf(errf, sizeof errf, "%s/dup.%s.err", td, stg[s].tag); + snprintf(cmd, sizeof cmd, + "timeout 240 %s/%s build --sep -I %s -o %s %s >/dev/null 2>%s", + bin, stg[s].drv, td, prog, droot, errf); + int rc = runwait(cmd); + if (rc == 0) { + fprintf(stderr, "separchive FAIL: %s accepted a masked " + "cross-pkg dup through .a (exit 0 — PASS 3 missing?)\n", + stg[s].drv); + fail++; + } + if (!file_has(errf, "duplicate symbol")) { + fprintf(stderr, "separchive FAIL: %s missing 'duplicate " + "symbol' on the .a dup path\n", stg[s].drv); + fail++; + } + if (access(prog, 0) == 0) { + fprintf(stderr, "separchive FAIL: %s produced a partial " + "binary on the dup reject\n", stg[s].drv); + fail++; + } + } + + /* Leg 4 NON-VACUITY: give pkgb a DISTINCT symbol → no collision → + * both stages build + run (exit 3 = 1 + 2). Proves the dup leg + * actually discriminates (not a vacuous always-fail). */ + if (write_file(pbww, + "package pkgb;\n" + "@symbol(\"uniq_sym\") export fn bfn() i32 = { return 2; };\n")) { + fail++; goto out; + } + for (int s = 0; s < 2; s++) { + char prog[1100]; + snprintf(prog, sizeof prog, "%s/ok.%s", td, stg[s].tag); + snprintf(cmd, sizeof cmd, + "timeout 240 %s/%s build --sep -I %s -o %s %s >/dev/null 2>&1", + bin, stg[s].drv, td, prog, droot); + if (runwait(cmd) != 0) { + fprintf(stderr, "separchive FAIL(non-vacuity): %s could not " + "build the distinct-symbol graph\n", stg[s].drv); + fail++; + continue; + } + int rc = runwait(prog); + if (rc != 3) { + fprintf(stderr, "separchive FAIL(non-vacuity): %s prog " + "exit=%d expected 3\n", stg[s].drv, rc); + fail++; + } + } + +out: + snprintf(cmd, sizeof cmd, "rm -rf %s", td); + runwait(cmd); + if (fail) { + fprintf(stderr, "separchive: %d check(s) failed\n", fail); + return 1; + } + printf("separchive: per-pkg .a (root=.o force-load, deps=.a) build+run " + "(exit %d) + .a determinism (3x identical) + cs.a==ww.a (rule 10) + " + "#31 masked-dup reject THROUGH .a (PASS 3, both stages loud+non-zero, " + "non-vacuity flip exit 3)\n", EXPECT_EXIT); + return 0; +}