Skip to content

cgen,builder,markused,vcache: make -usecache correct for multi-module builds (fix #27592)#27658

Open
eptx wants to merge 7 commits into
vlang:masterfrom
cx-home:fix/usecache-correctness
Open

cgen,builder,markused,vcache: make -usecache correct for multi-module builds (fix #27592)#27658
eptx wants to merge 7 commits into
vlang:masterfrom
cx-home:fix/usecache-correctness

Conversation

@eptx

@eptx eptx commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Fixes the family of -usecache correctness bugs root-caused in #27592 (duplicate __global symbols there are one instance of a general pattern), and makes cache invalidation sound. Includes a multi-module regression test.

Root cause

Under -usecache the program TU is compiled with skip_unused ON while each cached module (v build-module) is compiled with skip_unused OFF, so the two disagree about what is defined vs referenced (and about type-index numbering). This PR consistently routes every cross-TU definition/initialization through a single owner — the program TU — with cached modules referencing extern.

Layers fixed

  • __globals (-usecache link failure on the clang/gcc backend (macOS arm64): duplicate __global symbols (g_autostr_*_stack) from cached builtin.o #27592): extern globals emit decl-only in the program TU instead of a duplicate definition; g_main_argc/g_main_argv are defined by the program TU (they otherwise have no definition at all under -usecache).
  • fixed-array globals: the _vinit-time init form dst = *(T*)&((T[]){..}[0]) is illegal C for arrays (not assignable) — emit memcpy, mirroring the existing struct path.
  • sumtype is: the fallthrough compared a module-local int(right_type) literal while the cast wrappers and match use the unified _v_type_idx_<T>() value — so x is T silently disagrees with how the value was tagged across cached objects (misdispatch, no diagnostic). Now type_sidx() (a no-op for plain builds).
  • consts: cached objects carry their consts as per-TU statics, but only the program TU has _vinit const-init code — a cached module's consts stay zeroed (e.g. max_int == 0 inside builtin.o makes array.push panic on any growth; strconv's tables read as zeros). Each cached object now emits an idempotent <mod>__init_consts(), and the program TU calls them all before its own inline const inits (so const-generator functions that run in cached objects, e.g. ed25519's d_const, see live values).
  • closures: (a) closure_init() was only called when the program TU itself generated a closure (nr_closures > 0), so a closure created inside a cached module ran on an uninitialized trampoline pool → segfault; now gated on the whole-program used_features.anon_fn. (b) Bundled modules (util.bundle_modules: builtin.closure, builtin.overflow) are skipped in every build-module compile, yet the program TU also emitted them decl-only for non-test builds — their bodies existed in no object: undefined symbol: builtin__closure__closure_create_with_data for any non-test -usecache build that uses closures.
  • definition-#includes (C amalgamations): emitted only in the owning TU, so the same C definitions don't land in several cached objects.
  • test builds: handle_usecache cached builtin a second time via its absolute path (missing the import-loop guards on the test-module loop) → both objects linked, ~9000 duplicate symbols. Also, -usecache test builds re-emitted every used cached-module body in the program TU (blanket !is_test); now only fns defined in _test.v files get bodies there.
  • cache invalidation: source hashes were saved before invalidated modules were rebuilt, and v_build_module's exit status was ignored — one failed rebuild (racing edit, transient cc error) leaves a stale object that every later build silently links until a manual v wipe-cache. And cache keys ignored which compiler built an object, so a rebuilt v (after v self from uncommitted changes) reuses objects produced by an older cgen. Failed rebuilds are now loud + remove the stale object before hashes are recorded, and the cache namespace is salted with the running v executable's size+mtime.

Regression test

vlib/v/tests/usecache_multi_module_test.v + testdata/usecache_multi_module/: a small multi-module project exercising, across the program-TU/cached-module boundary: sumtype is, interface dispatch, closures created in the cached module, module consts / array growth (max_int), and float formatting (strconv's const tables in the cached builtin object). Compiled as a normal program and as a test build, cold and warm cache each, with an isolated VCACHE/VTMP. On vanilla master the program build fails to link and the test build has ~9000 duplicate symbols; with this PR everything passes.

Validation

(macOS arm64, -cc clang; every -usecache item below fails on vanilla master)

Happy to split this if preferred — the builder/vcache invalidation fixes and the cgen cross-TU fixes are cleanly separable.

Fixes #27592

eptx added 4 commits July 4, 2026 16:06
…_module

Fixed-size C arrays ([N]T) are not assignable with `=`, so the
`dst = *(T*)&((T[]){..}[0])` runtime-init form emitted illegal C
("array type ... is not assignable") whenever globals are zero-initialised
in _vinit rather than at declaration — the path taken under -usecache and
build_module. Emit memcpy for fixed-array globals, mirroring the existing
{E_STRUCT} memcpy path.
…builds

Fix a family of pre-existing -usecache bugs that made cached module builds
fail to link, miscompile, or crash. All share one root cause: under
-usecache the program translation unit is compiled with skip_unused ON
while each cached module (`v build-module`) is compiled with skip_unused
OFF, so the two disagree on what is defined vs referenced and on type
index numbering. The fix consistently routes every cross-TU definition and
initialization through the program TU as the single owner, with cached
modules referencing `extern`.

gen/c/infix.v
  Sumtype `is` compared a module-local type index (int(right_type)) while
  the matching cast/`match` used the unified g.type_sidx() expression, so
  `is` silently mismatched across TUs. Use g.type_sidx() for both.

gen/c/cgen.v
  - Interface (interface,concrete) dispatch index: emit as a program-TU
    `const u32` definition, referenced `extern` by cached modules, instead
    of a per-TU enum/local that does not survive linking. Guard the
    interface-table skip_unused prune so cached builds keep referenced
    method decls and complete iface/variant types.
  - Per-module const init: emit `void <mod>__init_consts(void)` in each
    cached module and call all of them from the program TU's init, before
    the inline const inits, so cached-module statics are initialized
    (previously left 0). Fixes edwards25519 uninit-read segfault, etc.
  - Closures: gate closure_init on table.used_features.anon_fn so the
    trampoline pool is initialized even when the program TU itself has
    nr_closures == 0 but a cached module uses closures.
  - Definition #include (".c" amalgamations, e.g. zstd) emitted only in
    the owning TU to avoid duplicate symbols across cached objects.
  - Sumtype cast fns, v_typeof_interface helper, and embed metadata made
    `static` / `extern`-declared appropriately under use_cache.

gen/c/consts_and_globals.v
  argv/argc globals defined in the program TU (extern in cached modules);
  extern globals emit decl-only (no re-init).

gen/c/fn.v
  Per-`_test.v` functions and non-owning-module methods emit decl-only in
  cached builds; guarded so builtin methods are not over-skipped. Bundled
  modules (util.bundle_modules, e.g. builtin.closure) are exempted from the
  use_cache builtin decl-only gate: they are skipped in every build-module
  compile, so no cached object carries their bodies and the program TU must
  emit them — otherwise closure_create_with_data etc. are undefined at link
  for any non-test -usecache build that uses closures.

gen/c/auto_str_methods.v, gen/c/embed.v
  Auto str fns and embed metadata use the non-parallel/static owner path.

markused/markused.v
  Mark non-generic interfaces under use_cache so their tables are complete.

builder/rebuilding.v
  handle_usecache: apply the import-loop guards (help / builtin /
  already-built / should-bundle) to the test's own non-main modules too.
  Without them a test build cached builtin a second time via its absolute
  path, linking two builtin objects and duplicating ~9000 symbols.
…ing, compiler-identity salt

Two cache-invalidation bugs that each leave a silently wrong (or unlinkable)
binary until the user manually wipes the cache:

1. CACHE POISONING: source hashes were saved BEFORE invalidated modules were
   rebuilt, and v_build_module ignored the rebuild exit status. One failed
   rebuild (racing edit, transient cc error) left the stale object in place
   with hashes already recorded as current — every later build silently
   linked the stale module object until a manual cache wipe (observed as a
   permanent link failure; an ABI-compatible variant is a silently WRONG
   binary). Now: failed rebuilds are loud, the stale object is removed
   (nothing stale can be served), and hashes are recorded only once nothing
   stale survives — the next run re-detects and retries. Wipe-free recovery
   verified.

2. COMPILER IDENTITY: cache keys ignored which compiler built the object, so
   a rebuilt v (v self / make) reused objects generated by an older cgen.
   The baked git hash is insufficient (v self from uncommitted changes keeps
   it): the cache namespace is now salted with the running v executable
   size+mtime.
A small multi-module project compiled with -usecache as a normal program
and as a test build, cold and warm cache each, with an isolated VCACHE.
Exercises, across the program-TU/cached-module boundary: sumtype `is`,
interface dispatch, closures created in the cached module, module consts
/ array growth (max_int), and float formatting (strconv's const tables
in the cached builtin object).

Fails on master (undefined symbols for the program build, duplicate
symbols for the test build); passes with the preceding fixes.
@medvednikov

Copy link
Copy Markdown
Member

V1 is going to be removed this month, so it's better to implement such things in V3.

@medvednikov

Copy link
Copy Markdown
Member

Well it'll still stay in oldv/ for 1 year, and will be available with -oldv. But it's no longer going to be developed.

@Jengro777

Copy link
Copy Markdown
Contributor

V1 is going to be removed this month, so it's better to implement such things in V3.

How do I use v3 now

Cached module objects emitted tentative definitions (common symbols) for
interface name tables, bundled-module globals (g_closure), and the argv
globals — relying on the linker merging commons across objects. Current
Apple ld and lld no longer merge cross-TU tentative definitions; they
error with 'N duplicate symbols', which broke every -usecache build
(2 dups for a tiny program, 328 for a real test binary).

Ownership now matches the -usecache design exactly, with no commons:
- interface name tables: build_module objects emit 'extern T x_name_table[N];'
  (program TU keeps the single initialized definition);
- bundled-module globals (builtin.closure / builtin.overflow): build_module
  objects reference extern; the program TU under -usecache is the single
  definition site and now also runs their initializers;
- g_main_argc/g_main_argv: builtin's own build_module compile no longer
  emits 'extern ... = 0;' (a definition) — declaration only, the generated
  main() in the program TU owns them.

Verified: nm on cached objects shows U (was __DATA,__common) for
IError_name_table / g_closure / g_main_argv; cold and warm -usecache
builds link and run.
@eptx

eptx commented Jul 5, 2026

Copy link
Copy Markdown
Contributor Author

Added one commit (8184f8cf52): cgen: no cross-TU tentative definitions under -usecache/build_module.

While validating downstream we hit another instance of the same class this PR addresses, worth fixing here too: cached module objects emit tentative definitions (common symbols) for interface *_name_tables, bundled-module globals (g_closure), and g_main_argc/g_main_argv — relying on the linker merging commons across objects. Current Apple ld and lld reject that (N duplicate symbols instead of merging), which can break every -usecache link on macOS depending on how many cached objects carry the tables. Cached objects now emit extern declarations; the program TU keeps the single initialized definition (and runs bundled-module global initializers under -usecache).

Validation on the branch: the multi-module regression test from this PR, the #27381 test, and vlib/v/vcache/ all pass; v fmt -verify clean. nm on cached objects shows U where it previously showed (__DATA,__common).

@JalonSolov

Copy link
Copy Markdown
Collaborator

V1 is going to be removed this month, so it's better to implement such things in V3.

How do I use v3 now

v vlib/v3
vlib/v3/v3 <rest of normal V command line>

…t cache invalidation

Two related defects made -usecache module objects disagree with the
program TU about $embed_file content:

1. RESOLUTION: a relative embed path probed the process CWD first and
   bound to it whenever a file existed there, using the documented
   source-relative location ("Paths can be absolute or relative to the
   source file", doc/docs.md) only as a fallback. Two consequences:
   - the -usecache `v build-module` child runs chdir-ed to VROOT
     (Builder.v_build_module), so a CWD-relative twin of the asset
     under VROOT's parent tree was silently baked into the cached
     module object while the program TU embedded the intended file —
     divergent embedded content across objects of one binary;
   - the "source file" anchor used for the fallback was Checker.file,
     which is the IMPORTING file's context when a module const
     initializer is checked from a use site, not the declaring file.
   Now the parser records the declaring file on ast.EmbeddedFile
   (source_file, like HashStmt.source_file), and the checker resolves
   relative paths against it FIRST, keeping CWD-relative resolution
   only as a backwards-compatibility fallback. Resolution is thereby
   CWD-independent: parent, build-module child and any later compile
   agree on the embedded file.

2. INVALIDATION: embedded assets are invisible to the .v source
   hashes, so editing ONLY the embedded file kept serving the stale
   cached module object forever. Every build-module object now records
   a .embeds.txt manifest (content hash + absolute path per checker-
   resolved $embed_file target, via the new ast.File.embedded_apaths),
   and Builder.rebuild_cached_module verifies it before serving a
   cache hit, rebuilding on any mismatch. A failed rebuild removes the
   stale object instead of serving it (the same poisoning class as the
   eager-hash-save bug).

Regression test: vlib/v/tests/usecache_embed_file_test.v builds a
module whose embed has a CWD-relative decoy twin from a foreign CWD
(resolution), then edits only the asset under a warm cache
(invalidation). Also green: vlib/v/embed_file 6/6, vlib/v/vcache.
@eptx

eptx commented Jul 5, 2026

Copy link
Copy Markdown
Contributor Author

Pushed one more -usecache correctness commit to this branch: 2ad1a2a — deterministic $embed_file resolution + embedded-asset cache invalidation.

Symptom class: with -usecache, a cached module object can silently carry different embedded content than the program TU, and editing only the embedded asset (no .v change) keeps serving the stale cached object forever.

Root causes (two, related):

  1. The checker resolved a relative $embed_file path against the process CWD first, using the documented source-relative location ("Paths can be absolute or relative to the source file", doc/docs.md) only as a fallback. The v build-module child runs chdir-ed to VROOT (Builder.v_build_module), so when a CWD-relative twin of the asset exists under VROOT's parent tree, the child bakes that twin into the cached .o while the main TU embeds the intended file. Additionally, the fallback's "source file" anchor was Checker.file, which is the importing file's context when a module const initializer is checked from a use site — not the declaring file. The parser now records the declaring file on ast.EmbeddedFile.source_file (same pattern as HashStmt.source_file) and the checker resolves source-relative first against it, keeping CWD-relative resolution as a backwards-compatibility fallback. Resolution is now CWD-independent, so parent and build-module child always agree.
  2. Embedded assets are invisible to the .v source hashes, so nothing ever invalidated a cached module object when only the asset changed. Each build-module object now records a .embeds.txt manifest (content hash + absolute path per resolved $embed_file target, via the new ast.File.embedded_apaths), and Builder.rebuild_cached_module verifies it before serving a cache hit; a failed rebuild removes the stale object instead of serving it.

Regression test: vlib/v/tests/usecache_embed_file_test.v — builds a module whose embed has a CWD-relative decoy twin while the compiler runs from a foreign CWD (resolution), then edits only the asset under a warm cache (invalidation). Verified: fails on the branch without this commit (built at 8184f8c), passes with it; vlib/v/tests/usecache_multi_module_test.v, vlib/v/embed_file/ (6/6) and vlib/v/vcache/ stay green (macOS arm64, clang).

Found in the wild: a git-worktree checkout sharing one V binary with its main checkout — $embed_file('../../asset') in the worktree's module cached the main checkout's copy of the asset, because the build-module child's VROOT-relative CWD probe hit the other checkout's file.

…v globals object-local

Three CI-surfaced fixes:

1. v_build_module: clear VFLAGS around the spawned 'v build-module' child.
   The parent already folds VFLAGS into the relayed b.pref.build_options;
   letting the child re-apply VFLAGS duplicates values ('-cflags x' -> 'x x'),
   so the child derives a different vcache key than the parent looks up and
   the freshly built object is never found ('could not rebuild cache module
   ... does not exist yet'). Pre-existing on master (reproducible with
   VFLAGS='-cc clang -cflags -fno-omit-frame-pointer' v -usecache run x.v);
   exposed by the new usecache tests under the sanitized CI jobs, which set
   exactly such a VFLAGS.

2. consts_and_globals: exclude -is_o outputs from the program-owned argv
   global definition — each -is_o output is a standalone hostable TU and
   several are expected to be linked together (link_generated_c_files_test),
   so g_main_argc/g_main_argv keep their object-local (static) linkage there.

3. consts_and_globals: the bundled-module should_init clause was dangling
   (comment lines between '||' continuations terminated the expression, so
   the final '|| (...)' parsed as a discarded statement — flagged by
   v_parser_test). Joined properly; the program TU now emits the initialized
   definition for bundled-module globals under -usecache as documented.

Validated: link_generated_c_files_test, v_parser_test, usecache_multi_module
+ usecache_embed_file (plain AND under the sanitize jobs' VFLAGS), vcache,
embed_file 6/6, fmt -verify + vet clean.
@eptx

eptx commented Jul 5, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 6bbd24692c fixing the three CI regressions this branch introduced/exposed (triaged every red job):

  1. link_generated_c_files_test (all jobs) — real regression from 8184f8cf52: the program-owned g_main_argc/g_main_argv definition leaked into -is_o outputs. Each -is_o output is a standalone hostable TU and several are expected to be linked together, so these now keep their object-local (static) linkage there, exactly as on master.

  2. v_parser_test (tcc-linux) — the bundled-module should_init clause in 8184f8cf52 was dangling: comment lines between || continuations terminated the expression, so the final || (...) parsed as a discarded statement (and the clause was semantically inert). Joined properly.

  3. usecache_multi_module_test / usecache_embed_file_test (sanitize jobs only) — these fail on vanilla master too, the new tests just exposed it: with a VFLAGS environment carrying -cflags … (as the sanitized jobs set), the spawned v build-module child re-applies VFLAGS on top of the relayed build_options, duplicating the cflags value — the child then derives a different vcache key than the parent looks up, and every -usecache build panics could not rebuild cache module … does not exist yet. Repro on master: VFLAGS='-cc clang -cflags -fno-omit-frame-pointer' v -usecache run hello.v. Fixed by clearing VFLAGS around the child spawn (the parent has already folded VFLAGS into the relayed options).

The remaining red I could find is master-side, not from this branch: db/pg/pg_config_test, x/executor/admission_test, the mbedtls concurrent-response flake, and sokol windows_backend_flags_test (already fixed on master by the recent "skip nested cross-compile" commit).

Validated locally: link_generated_c_files_test, v_parser_test, both usecache tests (plain and under the sanitize jobs' VFLAGS), vcache, embed_file 6/6, fmt -verify + vet clean.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

-usecache link failure on the clang/gcc backend (macOS arm64): duplicate __global symbols (g_autostr_*_stack) from cached builtin.o

4 participants