IC related bugfixes (#25946)

This commit is contained in:
Andreas Rumpf
2026-07-03 15:52:41 +02:00
committed by GitHub
parent c7ea004ca9
commit 1c61307692
45 changed files with 1453 additions and 131 deletions

View File

@@ -0,0 +1,2 @@
converter toBool*(x: int): bool = x != 0

14
tests/ic/memit.nim Normal file
View File

@@ -0,0 +1,14 @@
# Helper for temit.nim: a NON-main module with a module-scope `{.emit.}` that
# introduces a C macro consumed by an `{.importc, nodecl.}` const. The IC backend
# reload used to drop top-level emit pragmas — writeToplevelNode wrote them to the
# by-symbol-index implementation section, where a symbol-less pragma is never
# reloaded — so the generated C lost the `#define` and failed to compile with
# "use of undeclared identifier". Mirrors lib/pure/concurrency/cpuinfo.nim's
# `#include <sys/sysctl.h>` + `CTL_HW`/`HW_NCPU` importc pattern.
{.emit: """/*TYPESECTION*/
#define NIM_IC_EMIT_ANSWER 42
""".}
let icEmitAnswer {.importc: "NIM_IC_EMIT_ANSWER", nodecl.}: cint
proc emitAnswer*(): int = int(icEmitAnswer)

View File

@@ -0,0 +1,9 @@
# Calls `emptyOwned` from a DIFFERENT module than the one that owns it, so the
# reference at link time must resolve to a definition the owning module emits.
import memptyowned
proc callEmpty*(cond: bool) =
if cond:
emptyOwned()
echo "called ", cond

12
tests/ic/memptyowned.nim Normal file
View File

@@ -0,0 +1,12 @@
# Helper for `temptyowned`: exports a concrete proc whose body folds to
# nothing (an `nkEmpty` body, like Nimbus' `extras.incInternalErrors` when the
# metrics counter's `.inc()` expands to a no-op under `-u:metrics`). The proc is
# NOT called within its own module — only `memptycaller` references it — so the
# per-module backend's owned-routine seeding is the ONLY thing that can emit it.
template maybe*(x: untyped) =
when false:
x
proc emptyOwned*() =
maybe(echo "unreachable")

29
tests/ic/minitordera.nim Normal file
View File

@@ -0,0 +1,29 @@
# Helper for tinitorder (not a test itself; no `discard`).
#
# Imports minitorderb and, in its OWN init, reads the state minitorderb set up.
# With the wrong (importer-first) init order `gBState` is still 0 here. It also
# allocates a seq in its init so that under `--mm:refc` the GC (set up by the
# system module's init) must already be live — i.e. the system module's init has
# to be ordered first.
import minitorderb
var
gASawB = -1
gAItems: seq[int]
proc getASawB*(): int = gASawB
proc getACount*(): int = gAItems.len
proc recordA() =
gASawB = getBState()
gAItems = @[1, 2, 3]
# Allocate (and drop) enough garbage to force a GC cycle DURING module init.
# Under refc that runs a conservative stack scan, which needs the main
# thread's stack bottom already set — i.e. `initStackBottomWith` must run
# before the module inits, not after them.
for i in 0 ..< 100_000:
let s = @[i, i + 1, i + 2]
doAssert s.len == 3
recordA()

15
tests/ic/minitorderb.nim Normal file
View File

@@ -0,0 +1,15 @@
# Helper for tinitorder (not a test itself; no `discard`).
#
# An imported module whose INIT sets module-level state at runtime. Under the
# per-module backend its init must run BEFORE any importer's init (the imported
# module is a dependency → post-order). With the buggy importer-first order this
# module's `setupB` runs too late and importers observe `gBState == 0`.
var gBState: int
proc getBState*(): int = gBState
proc setupB() =
gBState = 42
setupB()

15
tests/ic/mmethanimal.nim Normal file
View File

@@ -0,0 +1,15 @@
# Helper for tmethitanium: the OWNER module of a `{.base.}` method. Its concrete
# base body is emitted here; the whole-program dispatcher is synthesized into the
# main module. Under `--debugger:native` the backend uses the Itanium mangling
# scheme, which encodes the signature instead of the `disamb`, so the base method
# and its same-signature dispatcher want the identical clean C name. The
# clean-vs-unique tie-break used to depend on a per-MODULE set (`mangledPrcs`),
# which the per-module IC backend cannot share — the base mangled clean at this
# owner but `speak_u<n>` (an unstable `itemId.item`) at every demander, so it was
# defined once and referenced under names nobody defined. See
# ccgutils.makeUnique (disamb, not itemId) + ccgtypes.fillBackendName.
type Animal* = ref object of RootObj
method speak*(a: Animal): string {.base.} =
"generic-animal-sound"

14
tests/ic/mmethdog.nim Normal file
View File

@@ -0,0 +1,14 @@
# Helper for tmethitanium: an override in a DIFFERENT module than the base, plus
# a `procCall` super-reference to the base from this non-owner module. That
# cross-module reference to the base impl is what diverged from the base's
# definition name under the per-module Itanium mangling.
import mmethanimal
type Dog* = ref object of Animal
method speak*(a: Dog): string =
"woof"
proc speakBoth*(a: Dog): string =
procCall(speak(Animal(a))) & "/" & speak(a)

7
tests/ic/temit.nim Normal file
View File

@@ -0,0 +1,7 @@
# Regression: a module-scope `{.emit.}` in an imported module must survive the
# `nim ic` backend reload (see memit.nim). Before the fix the dropped `#define`
# made the generated C fail to compile, so `nim ic` exited non-zero.
import memit
doAssert emitAnswer() == 42
echo emitAnswer()

18
tests/ic/temptyowned.nim Normal file
View File

@@ -0,0 +1,18 @@
discard """
output: '''called false
done'''
"""
# Regression test for the per-module IC backend dropping an owned routine whose
# body folds to `nkEmpty`. `memptyowned.emptyOwned` is a real, concrete, owned
# proc with an empty body; `memptycaller.callEmpty` references it across a module
# boundary. The owning module's `ownsRuntimeRoutine` seeding used to reject any
# routine with an `nkEmpty` body, so nobody emitted `emptyOwned` -> undefined
# reference at link (mirrors Nimbus `ncli` linking `extras.incInternalErrors`).
# Whole-program cgen always emits it as `void emptyOwned(void){}`; the per-module
# backend must too.
import memptycaller
callEmpty(false)
echo "done"

23
tests/ic/tinitorder.nim Normal file
View File

@@ -0,0 +1,23 @@
discard """
output: '''42 3'''
"""
# Regression test for per-module-backend module-init ORDERING.
#
# NimMain must call each module's init in DEPENDENCY (post-order) order: an
# imported module's init has to run before its importer's. The whole-program
# backend gets this for free (it iterates `modulesClosed`, built in module-finish
# order); the per-module backend reconstructs it in `nifbackend`. The earlier
# code iterated `bl.mods` by POSITION, which runs importers before their
# dependencies (an importer gets a lower file position than the modules it
# imports) — and the system module (which runs `initGC` in its init) was not
# ordered first at all.
#
# Module chain: tinitorder -> minitordera -> minitorderb. `minitorderb`'s init
# sets a global to 42; `minitordera`'s init reads it (and allocates a seq). With
# the buggy order `minitordera` runs first and reads 0 (or, under refc, crashes
# allocating before the GC is up). Correct order prints `42 3`.
import minitordera
echo getASawB(), " ", getACount()

46
tests/ic/tmeta_async.nim Normal file
View File

@@ -0,0 +1,46 @@
discard """
description: '''metamorphic IC: async/await across an edit (continuations + clean==incremental)'''
"""
# Async is the most cache-fragile area under `nim ic`: the `{.async.}` transform
# generates continuation closures and lifts environments, and those lowered
# bodies must survive incremental edits and converge to exactly what a clean
# build produces. This drives an edit to an async proc body and asserts the
# importer is NOT rebuilt (the lifted continuation stays local to its module),
# a no-op changes nothing, and the final state is byte-identical to a clean
# build. See doc/ic_ideas.md and [[ic-nimbus-test]] (async was the long pole).
#? metamorphic
#!FILE worker.nim
import std/asyncdispatch
proc compute*(x: int): Future[int] {.async.} =
await sleepAsync(0)
result = x * 2
#!FILE main.nim
import std/asyncdispatch, worker
echo waitFor compute(21)
#!STEP expect: 42
# --- edit the async proc body. The continuation env lives in `worker`, so only
# `worker` rebuilds; `main` (the caller) is left untouched -> modules: 1.
# (An async body edit does perturb `worker`'s interface cookie via the
# generated env type, so this is not asserted as a pure `body-edit`.)
#!FILE worker.nim
import std/asyncdispatch
proc compute*(x: int): Future[int] {.async.} =
await sleepAsync(0)
result = x * 3
#!STEP expect: 63; modules: 1
# --- re-emit identical content: nothing may change, lifted closures included.
#!FILE worker.nim
import std/asyncdispatch
proc compute*(x: int): Future[int] {.async.} =
await sleepAsync(0)
result = x * 3
#!STEP expect: 63; noop

View File

@@ -0,0 +1,64 @@
discard """
description: '''metamorphic IC: expression-based `let x = case ...` idiom'''
"""
# nimbus-eth2 leans on expression-style code (`let x = case ...` rather than
# statement assignment). This exercises that construct through the incremental
# path: a body edit that adds a `case` branch stays local to the module, while a
# return-type change (interface edit) propagates to the importer even though the
# importer's source is byte-identical. See doc/ic_ideas.md.
#? metamorphic
#!FILE classify.nim
proc classify*(n: int): string =
let kind = case n
of 0: "zero"
of 1, 2, 3: "small"
else: "big"
result = kind & "(" & $n & ")"
#!FILE main.nim
import classify
echo classify(0), " ", classify(2), " ", classify(99)
#!STEP expect: zero(0) small(2) big(99)
# --- body edit: add a `case` branch and tweak a label. The signature is
# unchanged, so no interface cookie changes and only `classify` rebuilds.
#!FILE classify.nim
proc classify*(n: int): string =
let kind = case n
of 0: "ZERO"
of 1, 2, 3: "small"
of 4, 5, 6: "medium"
else: "big"
result = kind & "(" & $n & ")"
#!STEP expect: ZERO(0) small(2) big(99); body-edit; modules: 1
# --- re-emit identical content: nothing may change.
#!FILE classify.nim
proc classify*(n: int): string =
let kind = case n
of 0: "ZERO"
of 1, 2, 3: "small"
of 4, 5, 6: "medium"
else: "big"
result = kind & "(" & $n & ")"
#!STEP expect: ZERO(0) small(2) big(99); noop
# --- interface edit: the expression-`case` now yields `int`, changing
# `classify`'s return type. `main`'s source is byte-identical (`echo` prints
# either) yet must re-sem & recodegen -> the cookie changes and 2 modules
# rebuild. The final step also runs the clean==incremental check.
#!FILE classify.nim
proc classify*(n: int): int =
result = case n
of 0: 0
of 1, 2, 3: 1
of 4, 5, 6: 5
else: 9
#!STEP expect: 0 1 9; iface-edit; modules: 2

View File

@@ -0,0 +1,33 @@
discard """
description: '''metamorphic IC: generic instantiation cache stability across edits'''
"""
# A generic lives in `gen` but is *instantiated* in `main` (the per-module
# backend emits instance bodies at the instantiation site). Editing the generic
# body must therefore rebuild both `gen` and `main`, and an incremental edit must
# still converge to exactly the same artifacts as a clean build. This exercises
# the static/generic-instance cache path that has historically been bug-prone.
#? metamorphic
#!FILE gen.nim
proc box*[T](x: T): seq[T] = @[x, x]
#!FILE main.nim
import gen
echo box(3).len, " ", box("hi")[0]
#!STEP expect: 2 hi
# --- edit the generic body: the importer holds the instantiations, so both the
# definer and the instantiation site rebuild (2 modules).
#!FILE gen.nim
proc box*[T](x: T): seq[T] = @[x, x, x]
#!STEP expect: 3 hi; modules: 2
# --- re-emit identical content: nothing may change.
#!FILE gen.nim
proc box*[T](x: T): seq[T] = @[x, x, x]
#!STEP expect: 3 hi; noop

61
tests/ic/tmeta_ortype.nim Normal file
View File

@@ -0,0 +1,61 @@
discard """
description: '''metamorphic IC: or-type (type-class union) idiom from nimbus forks.nim'''
"""
# nimbus-eth2 writes a lot of generic code over `A | B | C` type-class unions
# (consensus forks). This models that idiom: a `Fruit = Apple | Banana` union
# with a generic `describe[T: Fruit]` dispatched by `when T is ...`. The generic
# is instantiated in `main`, so editing its body rebuilds both modules, and the
# incremental result must match a clean build. See doc/ic_ideas.md.
#? metamorphic
#!FILE forks.nim
type
Apple* = object
weight*: int
Banana* = object
length*: int
Fruit* = Apple | Banana
proc describe*[T: Fruit](x: T): string =
when T is Apple: "apple " & $x.weight
else: "banana " & $x.length
#!FILE main.nim
import forks
echo describe(Apple(weight: 5)), " | ", describe(Banana(length: 9))
#!STEP expect: apple 5 | banana 9
# --- edit the generic body (no signature change). The union/constraint is
# untouched, so no interface cookie changes; but `main` holds the two
# instantiations, so both modules' codegen rebuilds.
#!FILE forks.nim
type
Apple* = object
weight*: int
Banana* = object
length*: int
Fruit* = Apple | Banana
proc describe*[T: Fruit](x: T): string =
when T is Apple: "APPLE " & $x.weight
else: "BANANA " & $x.length
#!STEP expect: APPLE 5 | BANANA 9; body-edit; modules: 2
# --- re-emit identical content: nothing may change.
#!FILE forks.nim
type
Apple* = object
weight*: int
Banana* = object
length*: int
Fruit* = Apple | Banana
proc describe*[T: Fruit](x: T): string =
when T is Apple: "APPLE " & $x.weight
else: "BANANA " & $x.length
#!STEP expect: APPLE 5 | BANANA 9; noop

93
tests/ic/tmeta_result.nim Normal file
View File

@@ -0,0 +1,93 @@
discard """
description: '''metamorphic IC: Result[T, E] (nim-results style) variant-object idiom'''
"""
# nimbus-eth2 threads errors through nim-results' `Result[T, E]`, a generic
# *variant* (case) object. This models a minimal hermetic version and exercises
# the generic-variant cache path across edits: a body edit of a generic accessor,
# adding a public overload (an interface edit), and a no-op — with a final
# clean==incremental check. See doc/ic_ideas.md.
#? metamorphic
#!FILE results.nim
type
ResultKind = enum rOk, rErr
Result*[T, E] = object
case kind: ResultKind
of rOk: v: T
of rErr: e: E
proc ok*[T, E](x: T): Result[T, E] = Result[T, E](kind: rOk, v: x)
proc err*[T, E](x: E): Result[T, E] = Result[T, E](kind: rErr, e: x)
proc isOk*[T, E](r: Result[T, E]): bool = r.kind == rOk
proc get*[T, E](r: Result[T, E]): T = r.v
proc error*[T, E](r: Result[T, E]): E = r.e
#!FILE main.nim
import results
proc parse(s: string): Result[int, string] =
if s == "42": ok[int, string](42)
else: err[int, string]("bad: " & s)
let a = parse("42")
let b = parse("x")
echo (if a.isOk: $a.get else: a.error), " ", (if b.isOk: $b.get else: b.error)
#!STEP expect: 42 bad: x
# --- body-only edit of a generic accessor (`error`): signature unchanged, so no
# interface cookie changes; the importer holds the instantiation, so both
# modules' codegen rebuilds.
#!FILE results.nim
type
ResultKind = enum rOk, rErr
Result*[T, E] = object
case kind: ResultKind
of rOk: v: T
of rErr: e: E
proc ok*[T, E](x: T): Result[T, E] = Result[T, E](kind: rOk, v: x)
proc err*[T, E](x: E): Result[T, E] = Result[T, E](kind: rErr, e: x)
proc isOk*[T, E](r: Result[T, E]): bool = r.kind == rOk
proc get*[T, E](r: Result[T, E]): T = r.v
proc error*[T, E](r: Result[T, E]): E = "ERR:" & r.e
#!STEP expect: 42 ERR:bad: x; body-edit; modules: 2
# --- re-emit identical content: nothing may change.
#!FILE results.nim
type
ResultKind = enum rOk, rErr
Result*[T, E] = object
case kind: ResultKind
of rOk: v: T
of rErr: e: E
proc ok*[T, E](x: T): Result[T, E] = Result[T, E](kind: rOk, v: x)
proc err*[T, E](x: E): Result[T, E] = Result[T, E](kind: rErr, e: x)
proc isOk*[T, E](r: Result[T, E]): bool = r.kind == rOk
proc get*[T, E](r: Result[T, E]): T = r.v
proc error*[T, E](r: Result[T, E]): E = "ERR:" & r.e
#!STEP expect: 42 ERR:bad: x; noop
# --- interface edit: add a public `get` overload (a new exported signature).
# `main` doesn't call it, yet importing `results` whose interface changed
# forces a re-sem; the cookie changes and >= 2 modules rebuild. The final
# step also runs the clean==incremental check.
#!FILE results.nim
type
ResultKind = enum rOk, rErr
Result*[T, E] = object
case kind: ResultKind
of rOk: v: T
of rErr: e: E
proc ok*[T, E](x: T): Result[T, E] = Result[T, E](kind: rOk, v: x)
proc err*[T, E](x: E): Result[T, E] = Result[T, E](kind: rErr, e: x)
proc isOk*[T, E](r: Result[T, E]): bool = r.kind == rOk
proc get*[T, E](r: Result[T, E], fallback: T): T = (if r.kind == rOk: r.v else: fallback)
proc get*[T, E](r: Result[T, E]): T = r.v
proc error*[T, E](r: Result[T, E]): E = "ERR:" & r.e
#!STEP expect: 42 ERR:bad: x; iface-edit; modules: 2

50
tests/ic/tmeta_smoke.nim Normal file
View File

@@ -0,0 +1,50 @@
discard """
description: '''metamorphic IC: clean==incremental, no-op stability, body vs interface boundary'''
"""
# This is a *metamorphic* IC test (see the `#? metamorphic` marker and the
# runner in testament/categories.nim). It drives a sequence of cross-module
# edits through `nim ic` in one fixed build directory and checks the invariants
# the incremental backend must uphold (doc/ic_ideas.md).
#? metamorphic
#!FILE a.nim
proc greet*(): string = "hi"
proc secret(): int = 41 # private, body-only churn target
proc value*(): int = secret() + 1
#!FILE main.nim
import a
echo greet(), " ", value()
#!STEP expect: hi 42
# --- body-only edit: a private body changes, no signature does.
# => no `*.iface.bif` cookie changes, exactly 1 module's codegen rebuilds,
# the importer is left untouched.
#!FILE a.nim
proc greet*(): string = "hi"
proc secret(): int = 999
proc value*(): int = secret() + 1
#!STEP expect: hi 1000; body-edit; modules: 1
# --- no-op edit: re-emit byte-identical content. Nothing downstream may change.
#!FILE a.nim
proc greet*(): string = "hi"
proc secret(): int = 999
proc value*(): int = secret() + 1
#!STEP expect: hi 1000; noop
# --- interface edit: `value`'s return type changes (a signature change) while
# main.nim's source stays byte-identical. The interface cookie must change
# and the importer must be re-sem'd & recodegen'd (>= 2 modules rebuilt).
# The final step also runs the clean==incremental check.
#!FILE a.nim
proc greet*(): string = "hi"
proc secret(): int = 999
proc value*(): int64 = secret().int64 + 1
#!STEP expect: hi 1000; iface-edit

View File

@@ -0,0 +1,39 @@
discard """
description: '''metamorphic IC: edit propagation across a 3-module import chain'''
"""
# Chain: main -> b -> a. Demonstrates that a body edit stays local to the edited
# module, while an interface edit propagates to its direct importer but stops
# where the next signature is unchanged. See doc/ic_ideas.md and the runner in
# testament/categories.nim.
#? metamorphic
#!FILE a.nim
proc base*(): int = 1
#!FILE b.nim
import a
proc mid*(): int = base() + 10
#!FILE main.nim
import b
echo mid()
#!STEP expect: 11
# --- body-only edit of `a.base`: no signature changes, so nothing re-sems;
# only module `a`'s own codegen rebuilds.
#!FILE a.nim
proc base*(): int = 7
#!STEP expect: 17; body-edit; modules: 1
# --- interface edit of `a.base` (return type int -> int64). `b` uses `base`, so
# `a`'s cookie change forces `b` to re-sem & recodegen; but `b.mid`'s own
# signature is unchanged, so `main` is NOT rebuilt -> exactly 2 modules.
# The final step also runs the clean==incremental check.
#!FILE a.nim
proc base*(): int64 = 7
#!STEP expect: 17; iface-edit; modules: 2

33
tests/ic/tmethitanium.nim Normal file
View File

@@ -0,0 +1,33 @@
discard """
output: '''woof
generic-animal-sound
generic-animal-sound/woof'''
"""
# NOTE: the `--debugger:native` that triggers the Itanium mangling lives in the
# sibling `tmethitanium_temp.nim.cfg` (the IC test harness compiles the generated
# `_temp.nim` and does not thread a `matrix`/`$options` switch into the cg
# children; a project cfg is read by the driver, which forwards it).
# Regression test: under `nim ic --debugger:native` the per-module backend uses
# the Itanium C name mangling, which encodes the signature and drops the
# `disamb`. A `{.base.}` method (owner module mmethanimal) and its whole-program
# dispatcher (synthesized into this main module) then share a signature, so the
# clean-name uniqueness probe (`m.g.mangledPrcs`) — which only sees the current
# module — gave the base a clean name at its owner but an unstable
# `itemId.item`-based `speak_u<n>` at each demander. Result: the base was defined
# once (clean) but referenced under names defined nowhere ("undefined reference
# to speak_u1") while the dispatcher collided with the clean base ("multiple
# definition of speak"). This was the bulk of nimbus-eth2's libp2p method link
# failures under `nim ic`. Fixed by making the Itanium scheme use the stable
# `disamb` (ccgutils.makeUnique) and always uniquify routine names under the
# per-module backend (ccgtypes.fillBackendName), plus forwarding
# `--debugger:native` to the cg children (deps.computeForwardedArgs).
import mmethanimal, mmethdog
let a: Animal = Dog()
echo speak(a) # dispatches -> override
let b: Animal = Animal()
echo speak(b) # dispatches -> base body
echo speakBoth(Dog()) # procCall to base from non-owner module

View File

@@ -0,0 +1,38 @@
discard """
output: '''9'''
"""
# Regression test for object-field serialization of static-generic instances
# under `nim ic`.
#
# A generic object's instances SHARE one field PSym (same itemId) while each
# instance carries a DISTINCT field type, e.g. `Digest[32].data: array[32,byte]`
# vs `Digest[48].data: array[48,byte]` (this is exactly nimcrypto's `MDigest`,
# which crashed compiling nimbus's `altair.nim`). The `.s.bif` writer DEFs each
# field once inside its owning type's reclist and references it as a bare SymUse
# elsewhere, deduping by a per-Writer `emittedFieldSyms` set. That set wrongly
# spanned DIFFERENT type reclists: after the first instance's `data` def, every
# other instance's reclist got a typeless SymUse stub instead of its own typed
# def. On load that field had a nil `typ`/`owner`, and `=destroy` lifting
# (`liftdestructors.fillBodyObj`) dereferenced it -> SIGSEGV. The fix scopes the
# dedup per-reclist so each instance reclist is a self-contained typed def.
#
# The `seq` field forces `=destroy` to be lifted for `Outer`, which walks the
# reclists of both `Digest` instances (the crash path).
type
Digest[n: static int] = object
data: array[n, byte]
Outer = object
a: Digest[32]
b: Digest[48]
s: seq[int]
proc use(o: Outer): int =
result = o.a.data[0].int + o.b.data[0].int + o.s.len
var o: Outer
o.a.data[0] = 4
o.b.data[0] = 2
o.s = @[1, 2, 3]
echo use(o)

View File

@@ -28,3 +28,17 @@ block:
fun[(int, string)]()
fun[ref Foo]()
fun[seq[int]]()
block: # shrinking an `@[...]` seq literal in the VM
# A `@[a, b, c]` seq value keeps the array-literal type in the VM; shrinking it
# to length 1 must not be misread as a broadcast default array (was: the whole
# thing collapsed to `len` copies of the first element).
proc shrink =
var s = @[10, 20, 30]
s.setLen(1)
doAssert s == @[10]
var t = @["foo", "bar"]
t.delete(1)
doAssert t == @["foo"]
static: shrink()
shrink()