IC: the cg stage must not write emit's .c

`registerModuleCode` writes the module's `.c` and registers it for compilation.
Under `--icBackendStage:cg` it must do neither: `cg`'s product is the `.c.nif`,
the `.c` is rendered by `emit` from that artifact plus the GLOBAL merge
decision, and the compile registration belongs to `link`.

Writing it in `cg` puts a second, differently-filtered `.c` — `cg` can only
filter by the liveness its own process sees — at the exact path `emit` declares
as its nifmake output. Two stages claim one output, and the `.c` ends up newer
than `emit`'s own `.c.nif` input, so nifmake considers `emit` up to date and
skips it: `cg`'s unfiltered text goes to the linker and every emit-everywhere
body is defined twice ("multiple definition of eqdup__u727466987__OOZ…").

Nothing surfaces this today because `merge` rewrites the decision file on every
run and every `emit` lists it as an input, so all of them re-fire and overwrite
the stray file. The fire-all is therefore load-bearing, not the "insurance" it
is documented as — and it is what makes a content-stable merge decision
(219 fewer processes and ~13s less backend CPU per edit on a 219-module
program) impossible: that experiment is exactly how this was found.

No behaviour change on its own, since the fire-all still runs. Green: `bootic`
fixed point, 16/16 metamorphic IC tests, the 17-file `koch ic` suite, and 13/13
differential edit checks against `nim c`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
araq
2026-08-28 22:38:14 +02:00
parent 3c53629164
commit d81e764f98

View File

@@ -2929,6 +2929,23 @@ proc genModuleCode(m: BModule; cf: var Cfile): string =
proc registerModuleCode(m: BModule; cf: var Cfile; code: string) =
## Second half of `writeModule`: writes the .c file if it changed and
## registers it for compilation.
##
## NOT under the per-module backend's `cg` stage. There the `.c` belongs to
## `emit`, which renders it from the `.c.nif` using the GLOBAL merge decision;
## `cg` can only filter by the liveness its own process can see, so writing
## here puts a second, differently-filtered `.c` at the very path `emit`
## declares as its nifmake output. Two stages then claim one output, and the
## `.c` ends up newer than `emit`'s own `.c.nif` input — so any build in which
## `emit` is not forced to run anyway keeps `cg`'s unfiltered text and hands it
## to the linker ("multiple definition of eqdup__…").
##
## Today nothing surfaces this: `merge` rewrites the decision file on every
## run and every `emit` lists it as an input, so all of them re-fire and
## overwrite the stray file. That makes the fire-all load-bearing rather than
## the "insurance" it is documented as, and it silently blocks making the
## decision content-stable. `cg`'s product is the `.c.nif`; the compile
## registration is likewise the `link` stage's job.
if m.config.cmd == cmdNifC and m.config.icBackendStage == "cg": return
if code != "" or m.config.symbolFiles != disabledSf:
when hasTinyCBackend:
if m.config.cmd == cmdTcc: