wcc,ww,os: atomic pkgcache store via temp+rename, both stages (#104)

The out/.pkgcache content-keyed store copied each artifact IN-PLACE
(cp -f / copyfile) to the fixed paths P.wwi/P.o/P.key. Key-last gave
crash-consistency but NOT concurrent-read safety: two same-stage builds
of a shared lib pkg (rt/time/os) target one out/.pkgcache/<pkg>/P.{wwi,o};
once an early finisher writes P.key, a later build's cache_lookup copies
P.wwi/P.o while a mid-finisher is still mid-write -> torn read -> corrupt
link / cs!=ww. The key is content-only, so it is purely the non-atomic
write.

Fix (Go-build-cache pattern, both stages in lock-step, rule 10): write
each artifact to a per-pid same-dir temp (P.wwi.tmp.<pid> etc.) then
rename() into place. Same dir => rename is atomic (cross-fs is not);
per-pid temp => concurrent writers don't clobber each other mid-copy;
content-keyed => last-writer-wins is byte-identical. Key renamed LAST so
a reader that sees the new key always finds complete artifacts. On any
mid-store error the per-pid temps are unlinked so a failed store leaves
no litter (cstage goto cleanup; wwstage cachermtmp helper).

  cstage cmd/ww/main.c cache_store: libc rename(2) + getpid().
  wwstage selfhost/cmd/ww/main.ww cachestore: new os.rename + cachetmp.
  lib/os/os.ww: add rename(2) (RENAME=82), ref/hare/os/os.ha:17 -- returns
    raw i32 errno like sibling remove/mkdir/rmdir (ww's os is the flat
    syscall floor, no fs:: layer); a second pathbuf2 slot holds newpath
    since kpath's single pathbuf can't carry both paths.

cache_lookup is unchanged: it reads cache->private scratch, and an atomic
source is never torn.

The torn-read race is closed BY CONSTRUCTION; a deterministic behavioral
regression-guard isn't feasible through the product build path (content-
keying => concurrent COLD builds all MISS+STORE, never HIT-read a mid-store
entry; a warm cache is never re-stored). The deferred white-box guard is
TASK #105. A WHY-comment at both fix sites records this.

Tests: 989_sepbuild_run KEEPS its private per-pid WW_PKGCACHE -- the
comment is corrected: the pin is NOT a torn-read mask (closed by
construction) but cold-compile isolation for the test's INTERMEDIATE
(.s/.unit.ww) byte-id compare, which a cache HIT legitimately skips
producing. The former 989_pkgcache_atomic_run is renamed to
989_pkgcache_concurrent_run and HONESTLY relabeled: it is a concurrent
shared-cache build-correctness smoke (N concurrent --sep builds sharing
one cache -> every binary byte-identical to an isolated reference + correct
run, both stages), NOT a torn-read/atomicity proof (a review revert-
experiment proved the original claim vacuous). Shrunk to 4 concurrent
builds x 1 batch x both stages. COLD/dev-only, off every byte-id/bootstrap
gate.

selfhost/cmd/ww/main.combined.ww remains stale (its writer was deleted at
the M4 E3-C1 flip; #90 deletes the file) -- not regenerated.

make test: all 445 passed; make sizelint clean; 990-997 byte-id hold.
This commit is contained in:
2026-06-18 20:20:37 +09:00
parent 33edc386f1
commit a9778ec000
6 changed files with 383 additions and 23 deletions

View File

@@ -37,6 +37,7 @@ type nr = enum i64 {
EXECVE = 59,
EXIT = 60,
WAIT4 = 61,
RENAME = 82,
MKDIR = 83,
RMDIR = 84,
UNLINK = 87,
@@ -89,6 +90,10 @@ export fn exit(code: i32) void = {
// .ai/probe_tagged_return_pointer_payload.ww.
export def PATH_MAX: i32 = 4096;
let pathbuf: [4096]u8;
// Second path slot: [[rename]] needs both old+new NUL-terminated at once,
// which the single [[pathbuf]] kpath slot can't hold (see kpath's
// non-reentrancy note).
let pathbuf2: [4096]u8;
// ref/hare/sys/+linux/types.ha:886-888. ww folds `sys` into `os`, so the
// std fd NUMBERS live here (the sys role). Typed i32, NOT io.file as in
@@ -301,6 +306,23 @@ export fn rmdir(path: str) i32 = {
return syscall1(nr.RMDIR, p: i64): i32;
};
// rename — rename(2). Atomic when oldpath and newpath are on the same
// filesystem; cross-fs is not. Returns 0 on success, negative errno
// otherwise. Mirrors Hare's os::rename (ref/hare/os/os.ha:17), but
// returns the raw i32 errno like sibling remove/mkdir/rmdir rather than
// Hare's (void | fs::error): ww's os is the flat syscall floor, with no
// fs:: error layer. newpath lands in the second [[pathbuf2]] slot since
// kpath's single [[pathbuf]] can't hold both paths at once.
export fn rename(oldpath: str, newpath: str) i32 = {
let p: *u8 = kpath(oldpath);
if (p == nil: *u8) { return -36i32; };
if (newpath.len + 1 >= PATH_MAX) { return -36i32; };
let i: i32 = 0;
for (i < newpath.len) { pathbuf2[i] = newpath[i]; i += 1; };
pathbuf2[newpath.len] = 0u8;
return syscall2(nr.RENAME, p: i64, (&pathbuf2[0]): i64): i32;
};
// mkdirs — recursive mkdir. Creates `path` and any non-existent
// parent directories with the given mode. EEXIST is silently
// accepted (matches Hare's `errors::exists` skip in os::mkdirs);