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.
This commit is contained in:
2026-06-22 20:52:11 +09:00
parent c0383274d1
commit a1484aef28
4 changed files with 291 additions and 0 deletions

View File

@@ -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
* <scratch>/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 <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/stat.h>
#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;
}
/* <scratch>/<pkg>.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;
}