From a1484aef28f565254157647e3fdd43e2b729f13c Mon Sep 17 00:00:00 2001 From: Hojun-Cho Date: Mon, 22 Jun 2026 20:52:11 +0900 Subject: [PATCH] pkgcache: reject 0-byte artifacts on store and lookup, self-heal torn writes (#10) A torn producer write (e.g. disk-full mid-copy) could leave a 0-byte P.wwi or P.o in out/.pkgcache under a self-consistent key; cache_lookup checked only existence, so every later build HIT and served the empty artifact forever (silent serve-wrong). Reject size==0 on both sides, symmetric across stages: store refuses to commit a 0-byte temp before the key write, lookup treats a 0-byte cached artifact as a MISS so existing poison self-heals on re-derive. A valid .wwi/.o is never 0 bytes, so the guard cannot misfire. Regression: test/wcc/989_pkgcache_poison_run.c, table-driven over {poison P.wwi | P.o | both}, non-vacuity proven by guard-neuter. --- Makefile | 11 ++ cmd/ww/main.c | 13 ++ selfhost/cmd/ww/main.ww | 20 +++ test/wcc/989_pkgcache_poison_run.c | 247 +++++++++++++++++++++++++++++ 4 files changed, 291 insertions(+) create mode 100644 test/wcc/989_pkgcache_poison_run.c diff --git a/Makefile b/Makefile index 98e77c30..cb28e26c 100644 --- a/Makefile +++ b/Makefile @@ -535,6 +535,7 @@ TESTS = $(BIN)/test_smoke $(BIN)/test_lex $(BIN)/test_parse $(BIN)/test_check \ $(BIN)/test_separchive_run \ $(BIN)/test_pkgcache_run \ $(BIN)/test_pkgcache_concurrent_run \ + $(BIN)/test_pkgcache_poison_run \ $(BIN)/test_c6soak_run \ $(BIN)/test_septest_run \ $(BIN)/test_coloimport_sep \ @@ -2951,6 +2952,16 @@ $(BIN)/test_pkgcache_concurrent_run: test/wcc/989_pkgcache_concurrent_run.c $(BI $(BIN)/w6l $(BIN)/w6l_ww $(LIB)/libwwrt.a | $(BIN) $(CC) $(CFLAGS) -o $@ $< +# 989_pkgcache_poison_run — #10 (BUG-B): a 0-byte cached P.wwi/P.o under a valid +# P.key (torn producer write) must self-heal — rejected on both lookup (MISS, +# re-derive) and store (never commit a 0-byte). Table-driven over which artifact +# is poisoned {wwi/o/both}, both driver stages. COLD/dev-only (off every byte-id/ +# bootstrap gate). Needs both driver + compiler + asm + linker stages + libwwrt.a. +$(BIN)/test_pkgcache_poison_run: test/wcc/989_pkgcache_poison_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 $@ $< + # 989_c6soak_run — M3-tail commit-6 dual-path soak gate, phase-1 leg (#46 # c6). Builds real lib chains (encoding.utf8 dotted + strings/bytes same-leaf # `contains`) BOTH combined and --sep, asserts combined==sep run-equivalence diff --git a/cmd/ww/main.c b/cmd/ww/main.c index 29cbdefa..adb26b0e 100644 --- a/cmd/ww/main.c +++ b/cmd/ww/main.c @@ -780,6 +780,17 @@ pkgcache_dir(struct sepgraph *g, int pi, char *out, size_t outsz) snprintf(out, outsz, "%s/%s", pkgcache_root(), base); } +/* A torn producer write (e.g. disk-full mid-copy) can leave a 0-byte P.wwi/P.o + * under a self-consistent key; size==0 is unambiguous poison (wwi_emit always + * writes >=1 line, a valid .o is never empty), so both store and lookup reject + * it — self-healing, Go-build-cache style (#10). */ +static int +filenonempty(const char *p) +{ + struct stat st; + return stat(p, &st) == 0 && st.st_size > 0; +} + /* HIT iff a freshly recomputed manifest equals the stored P.key byte-for-byte * AND both cached artifacts exist; on HIT copy them into the scratch wwi/obj * paths so the producer loop can skip compose+w6c+w6a. */ @@ -802,6 +813,7 @@ cache_lookup(struct sepgraph *g, int pi, const char *manifest, if (more || sn != strlen(manifest) || memcmp(stored, manifest, sn) != 0) return 0; if (access(cwwi, 0) != 0 || access(cobj, 0) != 0) return 0; + if (!filenonempty(cwwi) || !filenonempty(cobj)) return 0; snprintf(cmd, sizeof cmd, "cp -f '%s' '%s'", cwwi, wwi); if (run(cmd) != 0) return 0; snprintf(cmd, sizeof cmd, "cp -f '%s' '%s'", cobj, obj); @@ -847,6 +859,7 @@ cache_store(struct sepgraph *g, int pi, const char *manifest, if (run(cmd) != 0) goto cleanup; snprintf(cmd, sizeof cmd, "cp -f '%s' '%s'", obj, tobj); if (run(cmd) != 0) goto cleanup; + if (!filenonempty(twwi) || !filenonempty(tobj)) goto cleanup; f = fopen(tkey, "wb"); if (f == NULL) goto cleanup; fputs(manifest, f); diff --git a/selfhost/cmd/ww/main.ww b/selfhost/cmd/ww/main.ww index 63e3173f..b82c1070 100644 --- a/selfhost/cmd/ww/main.ww +++ b/selfhost/cmd/ww/main.ww @@ -1241,6 +1241,22 @@ fn copyfile(src: *u8, dst: *u8) i32 = { return 0; }; +// cachefilesize — open+filesize+close; -1 on any error. A torn producer write +// (e.g. disk-full mid-copy) can leave a 0-byte P.wwi/P.o under a self-consistent +// key; size==0 is unambiguous poison (wwi_emit always writes >=1 line, a valid +// .o is never empty), so both cachelookup and cachestore reject it — self-healing +// (#10). Twin of cstage filenonempty. +fn cachefilesize(path: *u8) i64 = { + let fd: i32 = os.open(pathstr(path), os.flag.RDONLY, 0i32); + if (fd < 0) { return -1i64; }; + let szr: (i64 | os.oserror) = os.filesize(fd); + os.close(fd); + match (szr) { + case let v: i64 => return v; + case let e: os.oserror => return -1i64; + }; +}; + // cachelookup — HIT iff the manifest equals the stored P.key byte-for-byte // AND both cached artifacts exist; on HIT copy them into the scratch // wwi/objf so the producer loop can skip compose+w6c+w6a. @@ -1260,6 +1276,8 @@ fn cachelookup(cacheroot: *u8, g: *sepgraph, pi: i32, manifest: *u8, }; if (os.access(pathstr(cwwi), 0i32) != 0) { return 0; }; if (os.access(pathstr(cobj), 0i32) != 0) { return 0; }; + if (cachefilesize(cwwi) <= 0i64) { return 0; }; + if (cachefilesize(cobj) <= 0i64) { return 0; }; if (copyfile(cwwi, wwi) != 0) { return 0; }; if (copyfile(cobj, objf) != 0) { return 0; }; return 1; @@ -1340,6 +1358,8 @@ fn cachestore(cacheroot: *u8, g: *sepgraph, pi: i32, manifest: *u8, let tkey: *u8 = cachetmp(dir, "P.key", pid); if (copyfile(wwi, twwi) != 0) { cachermtmp(twwi, tobj, tkey); return; }; if (copyfile(objf, tobj) != 0) { cachermtmp(twwi, tobj, tkey); return; }; + if (cachefilesize(twwi) <= 0i64) { cachermtmp(twwi, tobj, tkey); return; }; + if (cachefilesize(tobj) <= 0i64) { cachermtmp(twwi, tobj, tkey); return; }; let fd: i32 = os.open(pathstr(tkey), os.flag.WRONLY | os.flag.CREATE | os.flag.TRUNC, 420i32); // 0o644 if (fd < 0) { cachermtmp(twwi, tobj, tkey); return; }; diff --git a/test/wcc/989_pkgcache_poison_run.c b/test/wcc/989_pkgcache_poison_run.c new file mode 100644 index 00000000..cd3e8986 --- /dev/null +++ b/test/wcc/989_pkgcache_poison_run.c @@ -0,0 +1,247 @@ +/* + * 989_pkgcache_poison_run — out/.pkgcache 0-byte-artifact self-heal gate (#10, + * BUG-B). A torn producer write (e.g. disk-full mid-copy) can leave a 0-byte + * P.wwi/P.o under a SELF-CONSISTENT P.key. Pre-fix the cache served that empty + * artifact forever (the key matched and access() saw the file), so every later + * build link-failed or produced a wrong binary — a SILENT serve-wrong that + * never surfaces on its own. The fix rejects size==0 on BOTH the lookup (read) + * and the store (write) side: a poisoned entry self-heals (treated as MISS → + * re-derived), and a torn store never commits the key. The guard is symmetric + * cstage (filenonempty, stat) / wwstage (cachefilesize, open+filesize) — the + * hit/miss DECISION must match (rule 10). + * + * Target: a 2-level graph root(main) -> leaf in a private temp tree. ROOT is + * never cached (it is the build target); `leaf` is the cacheable package. We + * seed a warm cache, POISON the cached artifact to 0 bytes, rebuild, and assert + * the build self-heals: leaf re-compiles (MISS sentinel) AND the program runs + * to its correct value (not an empty/link-failed binary) AND the cache is + * re-stored non-empty. + * + * HIT/MISS sentinel (same as 989_pkgcache_run): a MISS runs w6c → writes + * /leaf.s; a HIT copies the cached .o/.wwi and skips compose+w6c+w6a, + * so no leaf.s. The scratch is wiped before each build so leaf.s presence + * reflects exactly the last build. + * + * Table-driven over WHICH artifact is poisoned: { P.wwi | P.o | both }. Each + * row, per stage (ww, ww_ww): + * - warm baseline: an unchanged rebuild HITs (leaf.s ABSENT, prog runs) — + * proves the cache is genuinely warm before we poison it (non-vacuity); + * - POISON the row's artifact(s) to 0 bytes; + * - heal rebuild: leaf re-compiles (leaf.s PRESENT = MISS, not served poison) + * AND prog runs == EXPECT_EXIT AND both cached artifacts are non-empty again + * (re-stored). Pre-fix this row served the 0-byte artifact and the assert + * on leaf.s (and on prog exit) fails. + * + * Light wwstage-driver test (CLAUDE.md rule 14): all build outputs and caches + * live under a private /tmp tree, wiped on exit, so it is parallel-safe and off + * every byte-id/bootstrap gate. Models 989_pkgcache_run 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 +write_file(const char *path, const char *body) +{ + FILE *f = fopen(path, "wb"); + if (!f) return -1; + fputs(body, f); + fclose(f); + return 0; +} + +/* Truncate `path` to 0 bytes (models a torn producer write under a valid key); + * "wb" opens-and-truncates, leaving the file present but empty. */ +static int +truncate0(const char *path) +{ + FILE *f = fopen(path, "wb"); + if (!f) return -1; + fclose(f); + return 0; +} + +static long +fsize(const char *path) +{ + struct stat st; + if (stat(path, &st) != 0) return -1; + return (long)st.st_size; +} + +/* /.s present ⇒ that package compiled this build (MISS). */ +static int +sdot(const char *scratch, const char *pkg) +{ + char p[1100]; + snprintf(p, sizeof p, "%s/%s.s", scratch, pkg); + return access(p, 0) == 0; +} + +static const char *leaf_src = + "package leaf;\n" + "export fn base() i32 = { return 7; };\n"; + +static const char *root_src = + "package main;\n" + "import leaf;\n" + "fn main() i32 = { return leaf.base(); };\n"; + +int +main(void) +{ + const char *bin = absbin(); + if (!bin) return 1; + int fail = 0; + char td[64], cmd[8192]; + char rootww[1024], leafww[1024]; + char scratch[1024], prog[1024]; + + snprintf(td, sizeof td, "/tmp/wwpkgpoison_%d", getpid()); + snprintf(cmd, sizeof cmd, "rm -rf %s", td); + runwait(cmd); + snprintf(cmd, sizeof cmd, "mkdir -p %s/leaf", td); + runwait(cmd); + + snprintf(rootww, sizeof rootww, "%s/root.ww", td); + snprintf(leafww, sizeof leafww, "%s/leaf/leaf.ww", td); + snprintf(prog, sizeof prog, "%s/p", td); + snprintf(scratch, sizeof scratch, "%s/p.sepwork", td); + + if (write_file(rootww, root_src) || write_file(leafww, leaf_src)) { + fail++; goto out; + } + + /* Which cached artifact(s) the row poisons. */ + struct { const char *tag; int wwi, obj; } rows[] = { + { "P.wwi", 1, 0 }, + { "P.o", 0, 1 }, + { "both", 1, 1 }, + }; + struct { const char *drv; } stg[] = { { "ww" }, { "ww_ww" } }; + + for (int s = 0; s < 2; s++) { + const char *drv = stg[s].drv; + char cache[1024], cwwi[1100], cobj[1100]; + snprintf(cache, sizeof cache, "%s/cache.%d", td, s); + snprintf(cwwi, sizeof cwwi, "%s/leaf/P.wwi", cache); + snprintf(cobj, sizeof cobj, "%s/leaf/P.o", cache); + + /* The cstage driver rm -rf's the scratch each build; the wwstage + * driver only mkdir's it (#58(a) asymmetry), so a stale leaf.s could + * persist and defeat the MISS/HIT sentinel. The gate owns its scratch: + * wipe it before every build so a fresh leaf.s reflects the last build. */ +#define BUILD() do { \ + snprintf(cmd, sizeof cmd, \ + "rm -rf %s; WW_PKGCACHE='%s' timeout 240 %s/%s build --sep " \ + "-o %s %s >/dev/null 2>&1", scratch, cache, bin, drv, prog, \ + rootww); \ + } while (0) + + /* fresh cache for this stage, cold-seed it (MISS) */ + snprintf(cmd, sizeof cmd, "rm -rf %s", cache); + runwait(cmd); + BUILD(); + if (runwait(cmd) != 0) { + fprintf(stderr, "poison FAIL[%s]: cold seed build failed\n", drv); + fail++; continue; + } + if (!sdot(scratch, "leaf")) { + fprintf(stderr, "poison FAIL[%s]: cold seed — leaf did not " + "compile (MISS expected)\n", drv); + fail++; + } + if (runwait(prog) != EXPECT_EXIT) { + fprintf(stderr, "poison FAIL[%s]: cold seed prog exit != %d\n", + drv, EXPECT_EXIT); + fail++; + } + + for (size_t r = 0; r < sizeof rows / sizeof rows[0]; r++) { + /* warm baseline — the cache is warm (heals re-store it), so an + * unchanged rebuild must HIT; proves we poison a LIVE entry. */ + BUILD(); runwait(cmd); + if (sdot(scratch, "leaf")) { + fprintf(stderr, "poison FAIL[%s/%s]: warm baseline — leaf " + "recompiled (cache not warm before poison)\n", + drv, rows[r].tag); + fail++; + } + + /* poison the row's artifact(s) to 0 bytes, key stays valid */ + if (rows[r].wwi && truncate0(cwwi) != 0) { + fprintf(stderr, "poison FAIL[%s/%s]: cannot truncate P.wwi\n", + drv, rows[r].tag); + fail++; + } + if (rows[r].obj && truncate0(cobj) != 0) { + fprintf(stderr, "poison FAIL[%s/%s]: cannot truncate P.o\n", + drv, rows[r].tag); + fail++; + } + + /* heal rebuild — a 0-byte artifact under a valid key must be + * treated as MISS (re-derived), NOT served. */ + BUILD(); runwait(cmd); + if (!sdot(scratch, "leaf")) { + fprintf(stderr, "poison FAIL[%s/%s]: served poison — leaf did " + "NOT re-derive after 0-byte artifact (silent serve-wrong)\n", + drv, rows[r].tag); + fail++; + } + if (runwait(prog) != EXPECT_EXIT) { + fprintf(stderr, "poison FAIL[%s/%s]: heal prog exit != %d " + "(empty/link-failed binary)\n", drv, rows[r].tag, EXPECT_EXIT); + fail++; + } + /* re-store must leave both artifacts non-empty (write guard let a + * good store through, AND a torn store never commits a 0-byte). */ + if (fsize(cwwi) <= 0 || fsize(cobj) <= 0) { + fprintf(stderr, "poison FAIL[%s/%s]: cached artifact still " + "0-byte after heal (re-store did not repair)\n", + drv, rows[r].tag); + fail++; + } + } +#undef BUILD + } + +out: + snprintf(cmd, sizeof cmd, "rm -rf %s", td); + runwait(cmd); + if (fail) { + fprintf(stderr, "poison: %d check(s) failed\n", fail); + return 1; + } + printf("poison: root->leaf via build_one_sep — a 0-byte cached P.wwi/P.o/both " + "under a valid key self-heals (treated as MISS, re-derived to correct " + "value, re-stored non-empty) — both stages\n"); + return 0; +}