ww: invalidate partial package commits

This commit is contained in:
2026-08-14 05:12:45 +09:00
parent 402f50ac8d
commit 351f4a25bc
5 changed files with 282 additions and 17 deletions

View File

@@ -4220,6 +4220,8 @@ build_one_sep_impl(const char *src, int entry_is_dir,
return 1;
}
int any_failed = 0;
int commit_open = 0;
int commit_integrity_failed = 0;
for (int i = 0; i < nproducts; i++)
if (g->pkg[products[i].root].failed) any_failed = 1;
for (int oi = 0; oi < norder; oi++) {
@@ -4255,6 +4257,8 @@ build_one_sep_impl(const char *src, int entry_is_dir,
const char *ca = warm ? anew : apath;
if (sep_discard_action_staging(warm, unitnew, wwinew, asmnew,
objnew, anew) < 0) {
if (warm && unlink(unitf) != 0 && errno != ENOENT)
commit_integrity_failed = 1;
g->pkg[pi].failed = 1;
any_failed = 1;
continue;
@@ -4262,6 +4266,8 @@ build_one_sep_impl(const char *src, int entry_is_dir,
if (sep_compose_unit(g, pi, cu) < 0) {
(void)sep_discard_action_staging(warm, unitnew, wwinew,
asmnew, objnew, anew);
if (warm && unlink(unitf) != 0 && errno != ENOENT)
commit_integrity_failed = 1;
g->pkg[pi].failed = 1;
any_failed = 1;
continue;
@@ -4271,6 +4277,7 @@ build_one_sep_impl(const char *src, int entry_is_dir,
if (g->pkg[g->pkg[pi].deps[k]].export_changed)
deps_changed = 1;
if (warm && !stale_all && !deps_changed
&& !commit_integrity_failed
&& file_equal(unitnew, unitf)
&& file_is_reg(asmf)
&& file_is_reg(wwi)
@@ -4284,6 +4291,20 @@ build_one_sep_impl(const char *src, int entry_is_dir,
}
continue;
}
/* Once an action is known not to be reusable, its old unit must no
* longer vouch for artifacts if any later producer or commit step
* fails. This is especially important when a dependency already
* committed a changed export during the same request. */
if (warm && unlink(unitf) != 0 && errno != ENOENT) {
fprintf(stderr, "ww: cannot invalidate package unit %s\n",
unitf);
(void)sep_discard_action_staging(warm, unitnew, wwinew,
asmnew, objnew, anew);
g->pkg[pi].failed = 1;
any_failed = 1;
commit_integrity_failed = 1;
continue;
}
int nmaps = 0;
for (int k = 0; k < g->pkg[pi].bindings.n; k++) {
if (!sep_binding_first_map(g, &g->pkg[pi].bindings, k))
@@ -4418,8 +4439,22 @@ build_one_sep_impl(const char *src, int entry_is_dir,
}
}
/* Commit order: artifacts before the unit that vouches for
* them, unit strictly last. */
* them, unit strictly last. Remove the workdir identity before the
* first artifact rename; failure to do so is a pre-commit rejection
* that leaves all committed artifacts untouched. */
if (warm) {
if (!commit_open) {
if (unlink(stampf) != 0 && errno != ENOENT) {
fprintf(stderr,
"ww: cannot invalidate package workdir\n");
g->pkg[pi].failed = 1;
any_failed = 1;
(void)sep_discard_action_staging(warm, unitnew,
wwinew, asmnew, objnew, anew);
continue;
}
commit_open = 1;
}
if (rename(wwinew, wwi) != 0
|| rename(asmnew, asmf) != 0
|| (!emit_asm && rename(objnew, obj) != 0)
@@ -4429,6 +4464,12 @@ build_one_sep_impl(const char *src, int entry_is_dir,
g->pkg[pi].path[0] ? g->pkg[pi].path : "(root)");
g->pkg[pi].failed = 1;
any_failed = 1;
/* A failed rename sequence may already have replaced the
* interface or another artifact. The stamp is already absent;
* also invalidate every unit voucher and force later actions in
* this invocation through their producers. */
commit_integrity_failed = 1;
(void)invalidate_workdir_units(scratch);
(void)sep_discard_action_staging(warm, unitnew, wwinew,
asmnew, objnew, anew);
continue;
@@ -4437,9 +4478,9 @@ build_one_sep_impl(const char *src, int entry_is_dir,
}
/* Stale passes removed every old unit voucher before compiling. Current
* successful units are therefore safe to vouch for even when a sibling
* root failed; a killed pass leaves the old identity and forces another
* invalidating pass, never false reuse. */
if (warm) {
* compiler rejects. A partial artifact commit invalidates all vouchers
* and suppresses the workdir identity so the next pass starts stale. */
if (warm && !commit_integrity_failed) {
if (!file_equal(toolw, self_path)
&& copy_file_atomic(self_path, toolw) != 0) {
fprintf(stderr, "ww: cannot record %s\n", toolw);
@@ -4455,7 +4496,7 @@ build_one_sep_impl(const char *src, int entry_is_dir,
fprintf(stderr, "ww: cannot record %s\n", toola);
free(order); return 1;
}
if (!stampok) {
if (!stampok || commit_open) {
char stampnew[PATH_MAX];
int sn = snprintf(stampnew, sizeof stampnew, "%s.new", stampf);
if (sn < 0 || (size_t)sn >= sizeof stampnew) {

View File

@@ -4558,7 +4558,17 @@ loader-owned deterministic error before producers. Compiler-owned scope or use
errors may invoke the compiler, but the driver removes that action's staged
`.new` unit/export/assembly/object/archive files instead of renaming them. No
completion/status marker is written, and no publication occurs after a failed
compile.
compile. If an artifact rename itself fails after an earlier rename has
published part of a new generation, the driver removes every committed unit
voucher as well as the remaining staged files. Before the first artifact
rename, it must successfully remove the workdir identity stamp; failure is a
pre-commit rejection that leaves the prior artifact generation untouched. The
stamp remains absent after any partial commit, and later actions in that
invocation cannot take the freshness shortcut. Each non-reusable action also
loses its old unit voucher before its producers run. The next request must
therefore rebuild the incomplete action set and reconsider direct importers; a
partly replaced `.wwi` or a failed importer can never make an old importer
voucher look fresh.
Package kind follows the declaration. `package main`, not a path component,
marks a command. A path ending in `main` remains importable when it declares a

View File

@@ -236,7 +236,12 @@ union; command and production/internal/external/generated-main variants retain
canonical action ownership; vendor expansion changes identity but not the
declared qualifier; compiler argv contains only direct `.wwi` inputs; and its
named rejected actions leave neither committed nor staged action artifacts or a
published binary. Together with the existing directory, recursive, vendor,
published binary. It also forces a staged multi-artifact commit to fail after
the interface rename, then proves that both stages invalidate old unit vouchers
and reconsider the importer rather than accepting a mixed warm generation. A
separate injected stamp-removal failure proves the pre-commit gate leaves all
previously committed artifacts byte-identical.
Together with the existing directory, recursive, vendor,
exact-argv, command, and persistent-workdir observers, the package suite proves
archive-only link argv and exact warm/rejection-state behavior without
duplicating those broader mechanisms in this observer.

View File

@@ -5093,6 +5093,8 @@ fn buildonesepimpl(selfdir: *u8, src: *u8, entryisdir: i32,
return 1;
};
let anyfailed: bool = false;
let commitopen: bool = false;
let commitintegrityfailed: bool = false;
producti = 0;
for (producti < nproducts) {
if (g.pkg[products[producti].root].failed) { anyfailed = true; };
@@ -5139,6 +5141,12 @@ fn buildonesepimpl(selfdir: *u8, src: *u8, entryisdir: i32,
};
if (sepdiscardactionstaging(warm, unitnew, wwinew, asmnew,
objnew, anew) < 0) {
if (warm) {
let unitrr: i32 = os.remove(pathstr(unitf));
if (unitrr != 0 && unitrr != -2) {
commitintegrityfailed = true;
};
};
g.pkg[pi].failed = true;
anyfailed = true;
oi += 1;
@@ -5147,6 +5155,12 @@ fn buildonesepimpl(selfdir: *u8, src: *u8, entryisdir: i32,
if (sepcomposeunit(g, pi, cu) < 0) {
sepdiscardactionstaging(warm, unitnew, wwinew, asmnew,
objnew, anew);
if (warm) {
let unitrr: i32 = os.remove(pathstr(unitf));
if (unitrr != 0 && unitrr != -2) {
commitintegrityfailed = true;
};
};
g.pkg[pi].failed = true;
anyfailed = true;
oi += 1;
@@ -5162,7 +5176,7 @@ fn buildonesepimpl(selfdir: *u8, src: *u8, entryisdir: i32,
};
let fresh: bool = false;
if (warm) {
if (!staleall && !depschanged) {
if (!staleall && !depschanged && !commitintegrityfailed) {
fresh = fileequal(unitnew, unitf);
if (fresh) { fresh = fileisreg(asmf); };
if (fresh) { fresh = fileisreg(wwi); };
@@ -5190,6 +5204,22 @@ fn buildonesepimpl(selfdir: *u8, src: *u8, entryisdir: i32,
oi += 1;
continue;
};
// Once an action is not reusable, its old unit must not vouch for
// artifacts after a later producer or commit failure. This also makes a
// failed importer retry after a dependency committed a changed export.
if (warm) {
let unitrr: i32 = os.remove(pathstr(unitf));
if (unitrr != 0 && unitrr != -2) {
cerrpath("ww: cannot invalidate package unit ", unitf, "\n");
sepdiscardactionstaging(warm, unitnew, wwinew, asmnew,
objnew, anew);
g.pkg[pi].failed = true;
anyfailed = true;
commitintegrityfailed = true;
oi += 1;
continue;
};
};
{
let rawtest: bool = (istest != 0) && g.pkg[pi].root
&& g.pkg[pi].isdir == 0;
@@ -5378,9 +5408,23 @@ fn buildonesepimpl(selfdir: *u8, src: *u8, entryisdir: i32,
continue;
};
};
// Commit order: artifacts before the unit that vouches for
// them, unit strictly last.
// Commit order: artifacts before the unit that vouches for them, unit
// strictly last. Remove the workdir identity before the first artifact
// rename; failure is a pre-commit rejection with old artifacts intact.
if (warm) {
if (!commitopen) {
let stamprr: i32 = os.remove(pathstr(stampf));
if (stamprr != 0 && stamprr != -2) {
cerr("ww: cannot invalidate package workdir\n");
g.pkg[pi].failed = true;
anyfailed = true;
sepdiscardactionstaging(warm, unitnew, wwinew, asmnew,
objnew, anew);
oi += 1;
continue;
};
commitopen = true;
};
let bad: bool = false;
if (os.rename(pathstr(wwinew), pathstr(wwi)) != 0) {
bad = true;
@@ -5418,6 +5462,11 @@ fn buildonesepimpl(selfdir: *u8, src: *u8, entryisdir: i32,
};
g.pkg[pi].failed = true;
anyfailed = true;
// A failed rename sequence may already have replaced the
// interface or another artifact. The stamp is already absent;
// invalidate all vouchers and force later actions through tools.
commitintegrityfailed = true;
invalidateworkdirunits(scratch);
sepdiscardactionstaging(warm, unitnew, wwinew, asmnew,
objnew, anew);
oi += 1;
@@ -5427,9 +5476,10 @@ fn buildonesepimpl(selfdir: *u8, src: *u8, entryisdir: i32,
oi += 1;
};
// A stale pass removed every old unit voucher before compiling. Current
// successful units remain safe to vouch for when a sibling root fails;
// a killed pass retains the old identity and invalidates again next time.
if (warm) {
// successful units remain safe when a sibling compiler rejects. A partial
// artifact commit invalidates all vouchers and suppresses the workdir
// identity so the next pass starts stale.
if (warm && !commitintegrityfailed) {
let sametool: bool = fileequal(toolw, selfpath);
if (sepfatalallocation) { return 1; };
if (!sametool) {
@@ -5456,7 +5506,7 @@ fn buildonesepimpl(selfdir: *u8, src: *u8, entryisdir: i32,
};
};
};
if (!stampok) {
if (!stampok || commitopen) {
if (writestampatomic(stampf, stampwant) != 0) {
cerrpath("ww: cannot record ", stampf, "\n");
return 1;

View File

@@ -7879,6 +7879,8 @@ fn runtimepath(relative: str) str = {
let scopetwo: str = strings.concat(source, "/scope/two");
let scoped: str = strings.concat(source, "/cmd/scoped");
let repeated: str = strings.concat(source, "/cmd/repeated");
let commitcodec: str = strings.concat(source, "/commit/codec");
let commitapp: str = strings.concat(source, "/cmd/commit");
let leafbad: str = strings.concat(source, "/cmd/leafbad");
let leak: str = strings.concat(source, "/cmd/leak");
let duplicate: str = strings.concat(source, "/cmd/duplicate");
@@ -7895,7 +7897,8 @@ fn runtimepath(relative: str) str = {
"/vend/vendor/short/codec");
let vendorclient: str = strings.concat(source, "/vend/client");
let dirs: []str = [source, tools, codec, bridge, direct, app, scopeone,
scopetwo, scoped, repeated, leafbad, leak, duplicate, collision,
scopetwo, scoped, repeated, commitcodec, commitapp, leafbad, leak,
duplicate, collision,
crosscollision, conflict, leafmain, program, leafmainuser, programuser, testcodec,
testhelper, vendored, vendorclient];
let di: i32 = 0;
@@ -7909,6 +7912,12 @@ fn runtimepath(relative: str) str = {
"export fn value() i32 = { return 42; };\n");
let codecfile: str = strings.concat(codec, "/codec.ww");
writefile(codecfile, wirebody);
let commitcodecfile: str = strings.concat(commitcodec, "/codec.ww");
let commitappfile: str = strings.concat(commitapp, "/main.ww");
writefile(commitcodecfile, wirebody);
writefile(commitappfile, strings.concat(
"package main;\nimport commit.codec;\n",
"fn main() i32 = { return wire.value(); };\n"));
writefile(strings.concat(bridge, "/bridge.ww"), strings.concat(
"package bridge;\nimport acme.codec;\n",
// WW retains its direct-import bare-declaration convenience. It is
@@ -8014,7 +8023,15 @@ fn runtimepath(relative: str) str = {
"for arg in \"$@\"; do printf '<%s>' \"$arg\" >> ",
"\"$WW_NAME_COMPILER_TRACE\"; done\n",
"printf '\\n' >> \"$WW_NAME_COMPILER_TRACE\"\n",
"exec \"$WW_NAME_REAL_COMPILER\" \"$@\"\n"));
"\"$WW_NAME_REAL_COMPILER\" \"$@\"\n",
"status=$?\n",
"if [ \"$status\" -eq 0 ] && ",
"[ -n \"$WW_COMMIT_BLOCK_STAMP\" ]; then\n",
" mv -- \"$WW_COMMIT_BLOCK_STAMP\" ",
"\"$WW_COMMIT_BLOCK_STAMP.saved\" || exit 97\n",
" mkdir -- \"$WW_COMMIT_BLOCK_STAMP\" || exit 98\n",
"fi\n",
"exit \"$status\"\n"));
let stages: []str = ["ww", "ww_ww"];
let compilers: []str = ["w6c", "w6c_ww"];
@@ -8032,6 +8049,16 @@ fn runtimepath(relative: str) str = {
strings.concat(root, "/ww-scoped")];
let repeatedbins: []str = [strings.concat(root, "/c-repeated"),
strings.concat(root, "/ww-repeated")];
let commitworks: []str = [strings.concat(root, "/c-commit-work"),
strings.concat(root, "/ww-commit-work")];
let commitcoldbins: []str = [strings.concat(root, "/c-commit-cold"),
strings.concat(root, "/ww-commit-cold")];
let commitblockbins: []str = [strings.concat(root, "/c-commit-block"),
strings.concat(root, "/ww-commit-block")];
let commitrejectbins: []str = [strings.concat(root, "/c-commit-reject"),
strings.concat(root, "/ww-commit-reject")];
let commitfixedbins: []str = [strings.concat(root, "/c-commit-fixed"),
strings.concat(root, "/ww-commit-fixed")];
let leafmainbins: []str = [strings.concat(root, "/c-leafmain"),
strings.concat(root, "/ww-leafmain")];
let vendorbins: []str = [strings.concat(root, "/c-vendor"),
@@ -8053,13 +8080,22 @@ fn runtimepath(relative: str) str = {
let scopedtraceref: str = "";
let repeatedtraceref: str = "";
let changedtraceref: str = "";
let commitfailref: str = "";
let commitpreflightref: str = "";
let commitbindingref: str = "";
let commitbinref: str = "";
let initialwwiref: str = "";
let testoutref: str = "";
let baseenv: []str = os.getenvs();
let si: i32 = 0;
for (si < stages.len) {
rewritefile(codecfile, wirebody);
rewritefile(commitcodecfile, wirebody);
rewritefile(commitappfile, strings.concat(
"package main;\nimport commit.codec;\n",
"fn main() i32 = { return wire.value(); };\n"));
assert(os.mkdir(works[si], 448i32) == 0);
assert(os.mkdir(commitworks[si], 448i32) == 0);
writefile(traces[si], "");
let env: []str = alloc([], (baseenv.len + 7): u64)!;
let ei: i32 = 0;
@@ -8071,7 +8107,9 @@ fn runtimepath(relative: str) str = {
&& !strings.hasprefix(baseenv[ei],
"WW_NAME_COMPILER_TRACE=")
&& !strings.hasprefix(baseenv[ei],
"WW_NAME_REAL_COMPILER=")) {
"WW_NAME_REAL_COMPILER=")
&& !strings.hasprefix(baseenv[ei],
"WW_COMMIT_BLOCK_STAMP=")) {
append(env, baseenv[ei]);
};
ei += 1;
@@ -8122,6 +8160,127 @@ fn runtimepath(relative: str) str = {
(30i64 * (time.second: i64)): time.duration, &out);
expectexit(&out, 42);
// A failed multi-file artifact rename may have published a new
// interface before a later rename fails. It invalidates every old unit
// voucher and the workdir identity, so the next request must reconsider
// an importer even when the regenerated dependency export now compares
// equal to that partially published interface.
let commitcoldav: []str = [driver(stages[si]), "build", "-w",
commitworks[si], "-I", source, "-o", commitcoldbins[si], commitapp];
runcommandenv(root, strings.concat("name-commit-cold-", stages[si]),
commitcoldav, env, (120i64 * (time.second: i64)): time.duration, &out);
expectexit(&out, 0);
let commitrun: []str = [commitcoldbins[si]];
runcommand(root, strings.concat("name-commit-cold-run-", stages[si]),
commitrun, (30i64 * (time.second: i64)): time.duration, &out);
expectexit(&out, 42);
rewritefile(commitcodecfile, cablebody);
let commitstamp: str = strings.concat(commitworks[si],
"/.wwtool.stamp");
let savedstamp: str = strings.concat(commitstamp, ".saved");
let oldcommitwwi: str = readfile(strings.concat(commitworks[si],
"/commit.codec.wwi"));
let oldcommits: str = readfile(strings.concat(commitworks[si],
"/commit.codec.s"));
let oldcommito: str = readfile(strings.concat(commitworks[si],
"/commit.codec.o"));
let oldcommita: str = readfile(strings.concat(commitworks[si],
"/commit.codec.a"));
let blockenv: []str = alloc([], (env.len + 1): u64)!;
let bei: i32 = 0;
for (bei < env.len) { append(blockenv, env[bei]); bei += 1; };
append(blockenv, strings.concat("WW_COMMIT_BLOCK_STAMP=", commitstamp));
let commitblockav: []str = [driver(stages[si]), "build", "-w",
commitworks[si], "-I", source, "-o", commitblockbins[si],
commitapp];
runcommandenv(root, strings.concat("name-commit-preflight-", stages[si]),
commitblockav, blockenv,
(120i64 * (time.second: i64)): time.duration, &out);
expectexit(&out, 1);
assert(has(out.stderr, "ww: cannot invalidate package workdir\n"));
if (si == 0) { commitpreflightref = strings.dup(out.stderr); }
else { assert(same(commitpreflightref, out.stderr)); };
assert(same(oldcommitwwi, readfile(strings.concat(commitworks[si],
"/commit.codec.wwi"))));
assert(same(oldcommits, readfile(strings.concat(commitworks[si],
"/commit.codec.s"))));
assert(same(oldcommito, readfile(strings.concat(commitworks[si],
"/commit.codec.o"))));
assert(same(oldcommita, readfile(strings.concat(commitworks[si],
"/commit.codec.a"))));
assert(!os.exists(strings.concat(commitworks[si],
"/commit.codec.unit.ww")));
assert(!os.exists(commitblockbins[si]));
assert(os.rmdir(commitstamp) == 0);
assert(os.rename(savedstamp, commitstamp) == 0);
// With the stamp preflight restored, force a later artifact rename to
// fail after the changed interface has already been published.
let blockedasm: str = strings.concat(commitworks[si],
"/commit.codec.s");
assert(os.remove(blockedasm) == 0);
assert(os.mkdir(blockedasm, 448i32) == 0);
let commitrejectav: []str = [driver(stages[si]), "build", "-w",
commitworks[si], "-I", source, "-o", commitrejectbins[si],
commitapp];
runcommandenv(root, strings.concat("name-commit-partial-", stages[si]),
commitrejectav, env,
(120i64 * (time.second: i64)): time.duration, &out);
expectexit(&out, 1);
assert(has(out.stderr, "ww: cannot commit commit.codec\n"));
if (si == 0) { commitfailref = strings.dup(out.stderr); }
else { assert(same(commitfailref, out.stderr)); };
assert(has(readfile(strings.concat(commitworks[si],
"/commit.codec.wwi")),
"//ww:module commit.codec\npackage cable;\n"));
assert(!os.exists(strings.concat(commitworks[si],
"/commit.codec.unit.ww")));
assert(!os.exists(strings.concat(commitworks[si],
"/cmd.commit.unit.ww")));
assert(!os.exists(strings.concat(commitworks[si], "/.wwtool.stamp")));
let partialsuffixes: []str = [".unit.new", ".wwi.new", ".s.new",
".o.new", ".a.new"];
let psi: i32 = 0;
for (psi < partialsuffixes.len) {
assert(!os.exists(strings.concat(commitworks[si],
"/commit.codec", partialsuffixes[psi])));
psi += 1;
};
assert(!os.exists(commitrejectbins[si]));
assert(os.rmdir(blockedasm) == 0);
rewritefile(traces[si], "");
runcommandenv(root, strings.concat("name-commit-reconsider-", stages[si]),
commitrejectav, env,
(120i64 * (time.second: i64)): time.duration, &out);
expectexit(&out, 1);
assert(has(out.stderr, "undefined: wire"));
let ncommitbinding: str = normalizedtrace(out.stderr,
strings.concat(commitworks[si], "/"), commitrejectbins[si]);
if (si == 0) { commitbindingref = strings.dup(ncommitbinding); }
else { assert(same(commitbindingref, ncommitbinding)); };
assert(has(readfile(traces[si]), "/cmd.commit.unit.new"));
assert(os.exists(strings.concat(commitworks[si],
"/commit.codec.unit.ww")));
assert(!os.exists(strings.concat(commitworks[si],
"/cmd.commit.unit.ww")));
assert(!os.exists(commitrejectbins[si]));
rewritefile(commitappfile, strings.concat(
"package main;\nimport commit.codec;\n",
"fn main() i32 = { return cable.value(); };\n"));
let commitfixedav: []str = [driver(stages[si]), "build", "-w",
commitworks[si], "-I", source, "-o", commitfixedbins[si], commitapp];
runcommandenv(root, strings.concat("name-commit-fixed-", stages[si]),
commitfixedav, env,
(120i64 * (time.second: i64)): time.duration, &out);
expectexit(&out, 0);
let commitfixedrun: []str = [commitfixedbins[si]];
runcommand(root, strings.concat("name-commit-fixed-run-", stages[si]),
commitfixedrun, (30i64 * (time.second: i64)): time.duration, &out);
expectexit(&out, 42);
let commitbinbytes: str = readfile(commitfixedbins[si]);
if (si == 0) { commitbinref = strings.dup(commitbinbytes); }
else { assert(same(commitbinref, commitbinbytes)); };
// The same declared qualifier belongs to each importing source file,
// not to the package-wide namespace.
rewritefile(traces[si], "");