From d81e764f987e011a36d01d82efdaad2869089474 Mon Sep 17 00:00:00 2001 From: araq Date: Fri, 28 Aug 2026 22:38:14 +0200 Subject: [PATCH] IC: the `cg` stage must not write `emit`'s .c MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- compiler/cgen.nim | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/compiler/cgen.nim b/compiler/cgen.nim index 3f7265e5ff..6d392ee4af 100644 --- a/compiler/cgen.nim +++ b/compiler/cgen.nim @@ -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: