From 48a70dd26e5cd605dcd5acbf2f66e44b2363f242 Mon Sep 17 00:00:00 2001 From: eptx Date: Tue, 30 Jun 2026 03:43:57 -0400 Subject: [PATCH 1/7] cgen: fix illegal fixed-array global assignment under -usecache/build_module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- vlib/v/gen/c/consts_and_globals.v | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/vlib/v/gen/c/consts_and_globals.v b/vlib/v/gen/c/consts_and_globals.v index 0ad20c2fa343fc..c16df6b8fc530a 100644 --- a/vlib/v/gen/c/consts_and_globals.v +++ b/vlib/v/gen/c/consts_and_globals.v @@ -657,7 +657,16 @@ fn (mut g Gen) global_decl(node ast.GlobalDecl) { if decls != '' { init = '\t${decls}' } - init += '\t${final_c_name} = *(${styp}*)&((${styp}[]){${default_initializer}}[0]); // global 5' + // Fixed-size C arrays (`[N]T`) are not assignable with `=`, so + // the `dst = *(T*)&((T[]){..}[0])` form emits illegal C ("array + // type ... is not assignable") when this runs in `_vinit` rather + // than at declaration — the path taken under `-usecache`/build_module. + // memcpy the default in, mirroring the `{E_STRUCT}` path above. + if g.table.final_sym(field.typ).kind == .array_fixed { + init += '\tmemcpy(${final_c_name}, (${styp}[]){${default_initializer}}, sizeof(${styp})); // global 5' + } else { + init += '\t${final_c_name} = *(${styp}*)&((${styp}[]){${default_initializer}}[0]); // global 5' + } } } } else { From c4cfc1989ef5a8368886337cb7597931a1b1d298 Mon Sep 17 00:00:00 2001 From: eptx Date: Wed, 1 Jul 2026 01:04:48 -0600 Subject: [PATCH 2/7] gen/c,builder,markused: make -usecache correct for multi-module test builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 __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. --- vlib/v/builder/rebuilding.v | 10 +- vlib/v/gen/c/auto_str_methods.v | 20 ++-- vlib/v/gen/c/cgen.v | 154 +++++++++++++++++++++++++++--- vlib/v/gen/c/consts_and_globals.v | 58 ++++++++++- vlib/v/gen/c/embed.v | 5 +- vlib/v/gen/c/fn.v | 35 ++++++- vlib/v/gen/c/infix.v | 9 +- vlib/v/markused/markused.v | 16 +++- 8 files changed, 275 insertions(+), 32 deletions(-) diff --git a/vlib/v/builder/rebuilding.v b/vlib/v/builder/rebuilding.v index 6246dae14042a2..87b68015e10794 100644 --- a/vlib/v/builder/rebuilding.v +++ b/vlib/v/builder/rebuilding.v @@ -227,7 +227,15 @@ fn (mut b Builder) handle_usecache(vexe string) { builtin_obj_path := b.rebuild_cached_module(vexe, 'vlib/builtin') libs << builtin_obj_path for ast_file in b.parsed_files { - if b.pref.is_test && ast_file.mod.name != 'main' { + // Cache the test's own non-main modules. Apply the same guards as the import + // loop below: builtin (and its parts: strconv/strings/math.bits/...) are already + // inside builtin.o, and a module already queued must not be built again — without + // these guards a test build caches builtin a SECOND time via its absolute path + // (a086..module.builtin.o AND 7c16..module..builtin.o), linking both and + // duplicating every builtin symbol (~9000 "duplicate symbol" errors at link). + if b.pref.is_test && ast_file.mod.name != 'main' && ast_file.mod.name != 'help' + && !util.module_is_builtin(ast_file.mod.name) && ast_file.mod.name !in built_modules + && !util.should_bundle_module(ast_file.mod.name) { imp_path := b.find_module_path(ast_file.mod.name, ast_file.path) or { verror('cannot import module "${ast_file.mod.name}" (not found)') break diff --git a/vlib/v/gen/c/auto_str_methods.v b/vlib/v/gen/c/auto_str_methods.v index dab65ea601b8e6..9010f5344a8617 100644 --- a/vlib/v/gen/c/auto_str_methods.v +++ b/vlib/v/gen/c/auto_str_methods.v @@ -31,8 +31,8 @@ fn (mut g Gen) gen_str_default(sym ast.TypeSymbol, styp string, str_fn_name stri } else { verror('could not generate string method for type `${styp}`') } - g.definitions.writeln('string ${str_fn_name}(${styp} it);') - g.auto_str_funcs.writeln('string ${str_fn_name}(${styp} it) {') + g.definitions.writeln('${g.static_non_parallel}string ${str_fn_name}(${styp} it);') + g.auto_str_funcs.writeln('${g.static_non_parallel}string ${str_fn_name}(${styp} it) {') if convertor == 'bool' { g.auto_str_funcs.writeln('\tstring tmp1 = builtin__string__plus(_S("${styp}("), ((${convertor})it ? _S("true") : _S("false")));') } else { @@ -226,10 +226,10 @@ fn (mut g Gen) gen_str_for_option(typ ast.Type, styp string, str_fn_name string, parent_str_fn_name := g.get_str_fn(parent_type) parent_cast_type := g.auto_str_storage_cast_type(parent_type) - g.definitions.writeln('string ${str_fn_name}(${styp} it);') - g.auto_str_funcs.writeln('string ${str_fn_name}(${styp} it) { return indent_${str_fn_name}(it, 0); }') - g.definitions.writeln('string indent_${str_fn_name}(${styp} it, ${ast.int_type_name} indent_count);') - g.auto_str_funcs.writeln('string indent_${str_fn_name}(${styp} it, ${ast.int_type_name} indent_count) {') + g.definitions.writeln('${g.static_non_parallel}string ${str_fn_name}(${styp} it);') + g.auto_str_funcs.writeln('${g.static_non_parallel}string ${str_fn_name}(${styp} it) { return indent_${str_fn_name}(it, 0); }') + g.definitions.writeln('${g.static_non_parallel}string indent_${str_fn_name}(${styp} it, ${ast.int_type_name} indent_count);') + g.auto_str_funcs.writeln('${g.static_non_parallel}string indent_${str_fn_name}(${styp} it, ${ast.int_type_name} indent_count) {') g.auto_str_funcs.writeln('\tstring res;') g.auto_str_funcs.writeln('\tif (it.state == 0) {') deref := if typ.is_ptr() && !typ.has_flag(.option_mut_param_t) { @@ -276,10 +276,10 @@ fn (mut g Gen) gen_str_for_result(typ ast.Type, styp string, str_fn_name string) parent_str_fn_name := g.get_str_fn(parent_type) parent_cast_type := g.auto_str_storage_cast_type(parent_type) - g.definitions.writeln('string ${str_fn_name}(${styp} it);') - g.auto_str_funcs.writeln('string ${str_fn_name}(${styp} it) { return indent_${str_fn_name}(it, 0); }') - g.definitions.writeln('string indent_${str_fn_name}(${styp} it, ${ast.int_type_name} indent_count);') - g.auto_str_funcs.writeln('string indent_${str_fn_name}(${styp} it, ${ast.int_type_name} indent_count) {') + g.definitions.writeln('${g.static_non_parallel}string ${str_fn_name}(${styp} it);') + g.auto_str_funcs.writeln('${g.static_non_parallel}string ${str_fn_name}(${styp} it) { return indent_${str_fn_name}(it, 0); }') + g.definitions.writeln('${g.static_non_parallel}string indent_${str_fn_name}(${styp} it, ${ast.int_type_name} indent_count);') + g.auto_str_funcs.writeln('${g.static_non_parallel}string indent_${str_fn_name}(${styp} it, ${ast.int_type_name} indent_count) {') g.auto_str_funcs.writeln('\tstring res;') g.auto_str_funcs.writeln('\tif (!it.is_error) {') if sym.kind == .string { diff --git a/vlib/v/gen/c/cgen.v b/vlib/v/gen/c/cgen.v index afde0c6eae15a8..9a053ac6e30173 100644 --- a/vlib/v/gen/c/cgen.v +++ b/vlib/v/gen/c/cgen.v @@ -691,6 +691,17 @@ pub fn gen(files []&ast.File, mut table ast.Table, pref_ &pref.Preferences) GenO if g.pref.build_mode != .build_module { // no init in builtin.o g.write_init_function() + } else { + // Under -usecache a cached module object carries its consts as file-local + // `static ...; // inited later` but historically had NO code to run those + // inits (only the program TU's _vinit did, on ITS own separate copies), so the + // cached object's copies stayed zero at runtime (e.g. builtin's `_const_max_int` + // == 0 → `array.push` panicking "len bigger than max_int"). Emit a per-module + // `${mod}__init_consts()` that runs this object's const inits; the program TU's + // _vinit calls it (see write_init_function). This is V's own long-standing TODO + // in const_decl ("encapsulate const initialisation for each module in a separate + // function, called by the top level program in _vinit"). + g.write_cached_module_init_consts_function() } if g.pref.is_coverage { @@ -1499,7 +1510,13 @@ pub fn (mut g Gen) write_typeof_functions() { if inter_info.is_generic { continue } - if g.pref.skip_unused && sym.idx !in g.table.used_features.used_syms { + // Mirror the interface-table emission (see the use_cache note there): under + // -usecache the program TU must NOT prune an interface's typeof helpers just + // because it isn't in this TU's own used_syms — a `typeof` on that interface + // (here or in a cached object) references v_typeof_interface[_idx]_, which + // would then be undeclared/undefined at link. + if g.pref.skip_unused && !g.pref.use_cache + && sym.idx !in g.table.used_features.used_syms { continue } if sym.cname in already_generated_ifaces { @@ -1551,6 +1568,14 @@ pub fn (mut g Gen) write_typeof_functions() { g.writeln('\tif (sidx == _${sym.cname}_${sub_sym.cname}_index) return ${u32(t.set_nr_muls(0))};') } g.writeln2('\treturn ${u32(ityp)};', '}') + } else if g.pref.use_cache || g.pref.build_mode == .build_module { + // A cached module object can `typeof` an interface too (e.g. net.socks + // on net.Connection), which references v_typeof_interface_idx_. That + // function is DEFINED only in the program TU (above), so emit an extern + // declaration here so the call compiles; it links to the program TU's + // definition. Without this the cached module fails to build ("could not + // rebuild cache module …") and everything importing it cascades. + g.definitions.writeln('u32 v_typeof_interface_idx_${sym.cname}(u32 sidx);') } } } @@ -4951,6 +4976,10 @@ fn (mut g Gen) get_sumtype_casting_fn(got_ ast.Type, exp_ ast.Type) string { fn (mut g Gen) write_sumtype_casting_fn(fun SumtypeCastingFn) { got, exp := fun.got, fun.exp + // Under -usecache these per-variant cast fns are generated independently in + // every object that uses the sumtype (program TU + each cached module), so + // external linkage collides at link. Emit them file-local instead. + sc := if g.pref.use_cache || g.pref.build_mode == .build_module { 'static ' } else { '' } got_sym, exp_sym := g.table.sym(got), g.table.sym(exp) mut got_cname, exp_cname := g.get_sumtype_variant_type_name(got, got_sym), exp_sym.cname mut type_idx := g.type_sidx(got) @@ -4958,8 +4987,8 @@ fn (mut g Gen) write_sumtype_casting_fn(fun SumtypeCastingFn) { mut variant_name := g.get_sumtype_variant_name(got, got_sym) if got_sym.kind == .alias && got_sym.info is ast.Alias && g.table.unaliased_type(got).idx() == exp.idx() { - g.definitions.writeln('${exp_cname} ${fun.fn_name}(${got_cname}* x, bool is_mut);') - sb.writeln('${exp_cname} ${fun.fn_name}(${got_cname}* x, bool is_mut) {') + g.definitions.writeln('${sc}${exp_cname} ${fun.fn_name}(${got_cname}* x, bool is_mut);') + sb.writeln('${sc}${exp_cname} ${fun.fn_name}(${got_cname}* x, bool is_mut) {') sb.writeln('\treturn *(${exp_cname}*)x;') sb.writeln('}\n') g.auto_fn_definitions << sb.str() @@ -4986,8 +5015,8 @@ fn (mut g Gen) write_sumtype_casting_fn(fun SumtypeCastingFn) { got_cname = g.styp(got) // g.definitions.writeln('${g.static_modifier} inline ${exp_cname} ${fun.fn_name}(${got_cname}* x);') // sb.writeln('${g.static_modifier} inline ${exp_cname} ${fun.fn_name}(${got_cname}* x) {') - g.definitions.writeln('${exp_cname} ${fun.fn_name}(${got_cname}* x, bool is_mut);') - sb.writeln('${exp_cname} ${fun.fn_name}(${got_cname}* x, bool is_mut) {') + g.definitions.writeln('${sc}${exp_cname} ${fun.fn_name}(${got_cname}* x, bool is_mut);') + sb.writeln('${sc}${exp_cname} ${fun.fn_name}(${got_cname}* x, bool is_mut) {') sb.writeln('\t${got_cname}* ptr = x;') sb.writeln('\tif (!is_mut) { ptr = builtin__memdup(x, sizeof(${got_cname})); }') } @@ -11116,10 +11145,35 @@ fn (mut g Gen) gen_hash_stmts(mut sb strings.Builder, node &ast.HashStmtNode, se line_nr := node.pos.line_nr + 1 match node.kind { 'include', 'preinclude', 'postinclude' { - guarded_include := g.hash_stmt_guarded_include(node) - sb.writeln('') - sb.writeln('// added by module `${node.mod}`, file: ${os.file_name(node.source_file)}:${line_nr}:') - sb.writeln(guarded_include) + // A `#include "foo.c"` pulls C *definitions* (an amalgamation like + // zstd.c), not just declarations. Under -usecache every module that + // transitively imports the owner parses the owner's files, so the + // definition-include would land in every such cached object AND the + // program TU -> hundreds of duplicate C symbols at link (e.g. _ZSTD_* + // in zstd.o and every importer's .o). Emit a definition-include only in the + // object that OWNS it: the module being built (build_module), or a + // non-cached module in the program TU. Other objects call the owner's + // V wrappers (external symbols in its cached object), needing neither + // the C definitions nor -- on the wrapper path -- the declarations. + // `.h` includes (declarations only) are always safe and never filtered. + inc_target := node.main.trim(' "<>') + is_def_include := inc_target.ends_with('.c') + owned_here := if g.pref.build_mode == .build_module { + node.mod == g.module_built + } else { + node.mod in ['main', 'help'] || util.should_bundle_module(node.mod) + } + if is_def_include && (g.pref.use_cache || g.pref.build_mode == .build_module) + && !owned_here { + sb.writeln('') + sb.writeln('// -usecache: skipped C definition-include "' + inc_target + + '" from cached module `${node.mod}` (compiled once into its own object)') + } else { + guarded_include := g.hash_stmt_guarded_include(node) + sb.writeln('') + sb.writeln('// added by module `${node.mod}`, file: ${os.file_name(node.source_file)}:${line_nr}:') + sb.writeln(guarded_include) + } } 'insert' { sb.writeln('') @@ -12085,6 +12139,34 @@ fn (mut g Gen) write_init_function() { g.writeln('\tbuiltin__builtin_init();') } + // -usecache: initialize every cached object's OWN const copies BEFORE the program + // TU's inline const inits below. Some of those inline inits call generate functions + // that live in a cached object and read that object's consts (e.g. edwards25519 + // d_const_generate reads d_bytes), so the cached consts must already be live. Emitted + // in g.table.modules (dependency) order; each ${mod}__init_consts is self-contained. + // builtin-family sub-modules (strings, strconv, math.bits, ...) are folded into + // builtin.o, so builtin__init_consts covers them; only `builtin` itself is called for + // that family. main/help/bundled modules are compiled into this TU, not cached. + if g.pref.use_cache { + // Call __init_consts() for exactly the objects handle_usecache links: + // builtin.o plus one object per non-builtin/non-bundled import. The builtin + // family (math.bits, strconv, strings, ...) folds into builtin.o, whose own + // builtin__init_consts covers the copies it carries; bundled modules + // (builtin.closure, ...) are emitted in the program TU and have no cached + // object at all. + for icmod in g.table.modules { + if icmod in ['main', 'help'] || util.should_bundle_module(icmod) { + continue + } + if icmod != 'builtin' && util.module_is_builtin(icmod) { + continue + } + icmod_c := util.no_dots(icmod) + g.definitions.writeln('void ${icmod_c}__init_consts(void);') + g.writeln('\t${icmod_c}__init_consts();') + } + } + // reflection bootstrapping if g.has_reflection { if var := g.global_const_defs['g_reflection'] { @@ -12143,7 +12225,17 @@ fn (mut g Gen) write_init_function() { } } - if g.nr_closures > 0 { + if g.nr_closures > 0 || (g.pref.use_cache && g.table.used_features.anon_fn) { + // Under -usecache the program TU emits cached-module functions declaration-only, + // so closures defined in those cached objects are NOT counted in this TU's + // nr_closures. If the program's own code has no closures, nr_closures is 0 and + // closure_init would be skipped — leaving the closure runtime (trampoline pool) + // uninitialized, so a closure created+called inside a cached object (e.g. a + // stored getter fn) dereferences a garbage captured context → segfault. Gate on + // the WHOLE-PROGRAM `used_features.anon_fn` (true iff any module uses closures): + // markused already marks closure_init used under that flag, so its declaration + // and definition are present; and when no module uses closures we must NOT emit + // the call (the closure runtime object is not built → link error). g.writeln('\tbuiltin__closure__closure_init();') } @@ -12226,6 +12318,33 @@ fn (mut g Gen) write_init_function() { } } +// write_cached_module_init_consts_function is the -usecache/build_module counterpart +// of write_init_function's per-module const-init loop. A cached module object has no +// _vinit of its own (write_init_function is skipped for build_mode == .build_module), +// so its file-local `static` consts would never be initialized at runtime. Emit a +// `${module_built}__init_consts()` that runs this object's const inits (in dependency +// order); the program TU's _vinit calls it once at startup (see write_init_function). +// It initializes every const emitted into THIS object — the module's own consts plus +// its static copies of imported consts it references — mirroring the program TU, which +// keeps its own copies. `once` makes repeated calls idempotent. +fn (mut g Gen) write_cached_module_init_consts_function() { + if g.pref.no_builtin { + return + } + mod_c := util.no_dots(g.module_built) + g.writeln('void ${mod_c}__init_consts(void);') + g.writeln('void ${mod_c}__init_consts(void) {') + g.writeln('\tstatic bool once = false; if (once) {return;} once = true;') + for var_name in g.sorted_global_const_names { + if var := g.global_const_defs[var_name] { + if var.init.len > 0 { + g.writeln(var.init) + } + } + } + g.writeln('}') +} + fn (mut g Gen) write_builtin_types() { if g.pref.no_builtin { return @@ -14059,7 +14178,20 @@ fn (mut g Gen) interface_table() string { if inter_info.is_generic { continue } - if g.pref.skip_unused && isym.idx !in g.table.used_features.used_syms { + // Under -usecache the program TU is the sole definition site for every + // interface's index symbols + name_table (it alone has the whole-program view + // of all implementors); cached module objects — built with skip_unused OFF — + // reference them. So the program TU must NOT prune an interface here just because + // it isn't used in the program TU's own trace: doing so leaves its + // `___index` undefined at link and its name_table a zeroed common + // symbol (silent misdispatch). Emitting the full table set is safe without + // pulling in extra code: unused concrete variants degrade to `voidptr` below (no + // method refs), and referenced methods/types are already DECLARED in the program + // TU (cached-module functions are emitted as declarations, see gen_fn_decl). This + // avoids force-marking every interface in markused, which would drag each + // interface's whole transitive closure (implementor method bodies, C-interop + // thirdparty sources, ...) into the program TU and duplicate it at link. + if g.pref.skip_unused && !g.pref.use_cache && isym.idx !in g.table.used_features.used_syms { continue } if isym.cname in already_generated_ifaces { diff --git a/vlib/v/gen/c/consts_and_globals.v b/vlib/v/gen/c/consts_and_globals.v index c16df6b8fc530a..78ad85ae0ba38b 100644 --- a/vlib/v/gen/c/consts_and_globals.v +++ b/vlib/v/gen/c/consts_and_globals.v @@ -49,8 +49,14 @@ fn (mut g Gen) const_decl(node ast.ConstDecl) { ast.ArrayInit { elems_are_const := field.expr.exprs.all(g.check_expr_is_const(it)) if field.expr.is_fixed && !field.expr.has_index - && g.pref.build_mode != .build_module && (!g.is_cc_msvc || field.expr.elem_type != ast.string_type) && elems_are_const { + // Initialize fixed-array consts at their C *definition* (`static T x = + // {..}`), not in _vinit: a fixed C array is not assignable (`x = {..}` + // is illegal), so a deferred runtime init cannot work for them. This + // used to be gated to non-build_module (cached modules deferred all + // const init to the program TU) — but a cached module now runs its own + // ${mod}__init_consts(), and emitting `x = {..}` there is exactly the + // illegal form. Initializing at definition is valid in every TU. styp := g.styp(field.expr.typ) val := g.expr_string(ast.Expr(field.expr)) // eprintln('> const_name: ${const_name} | name: ${name} | styp: ${styp} | val: ${val}') @@ -121,7 +127,10 @@ fn (mut g Gen) const_decl(node ast.ConstDecl) { g.expr_string(field_expr)) } else if field.expr is ast.CastExpr { if field.expr.expr is ast.ArrayInit { - if field.expr.expr.is_fixed && g.pref.build_mode != .build_module { + if field.expr.expr.is_fixed { + // Fixed-array cast const: initialize at definition, valid in + // every TU (a fixed C array cannot be assigned at runtime; see + // the ArrayInit fixed-array case above). styp := g.styp(field.expr.typ) val := g.expr_string(field.expr.expr) g.global_const_defs[name] = GlobalConstDef{ @@ -494,11 +503,19 @@ fn (mut g Gen) global_decl(node ast.GlobalDecl) { // was static used here to to make code optimizable? it was removed when // 'extern' was used to fix the duplicate symbols with usecache && clang // visibility_kw := if g.pref.build_mode == .build_module && g.is_builtin_mod { 'static ' } + // A global owned by the `main` module is only ever compiled into the program + // TU — `main` is never emitted as a cached `.o` (see Builder.handle_usecache, + // which caches builtin + imports + non-main test files, but never `main`). + // So under -usecache it must be *defined* here, not declared `extern`: an + // `extern` with no definition site links to nothing (the historic `test_runner` + // "Undefined symbols" failure). Every other module either owns a cached object + // (defined there, `extern` here) or is bundled (already excluded below). + is_program_module_global := g.pref.build_mode != .build_module && node.mod == 'main' visibility_kw := if g.should_use_object_local_linkage(node.mod) { 'static ' } else if (g.pref.use_cache || (g.pref.build_mode == .build_module && g.module_built != node.mod)) - && !util.should_bundle_module(node.mod) { + && !util.should_bundle_module(node.mod) && !is_program_module_global { 'extern ' } else { '' @@ -514,6 +531,7 @@ fn (mut g Gen) global_decl(node ast.GlobalDecl) { } should_init := (!g.pref.use_cache && g.pref.build_mode != .build_module) || (g.pref.build_mode == .build_module && g.module_built == node.mod) + || is_program_module_global mut attributes := '' first_field := node.fields[0] if first_field.is_weak { @@ -542,7 +560,35 @@ fn (mut g Gen) global_decl(node ast.GlobalDecl) { mut def_builder := strings.new_builder(100) mut init := '' extern := if field.is_extern { 'extern ' } else { '' } - field_visibility_kw := if field.is_extern { '' } else { visibility_kw } + // `g_main_argc`/`g_main_argv` are owned by the program entry point: the + // generated `main()` assigns them (gen_c_main_function_header) and their + // declared initializer is suppressed everywhere (see the skip below). So no + // module's build_module compile ever *defines* them — under -usecache the + // owning builtin object only declares them `extern` too, leaving them defined + // NOWHERE (a non-cache build gets a tentative definition in its single TU). + // Define them in the program TU (build_mode != build_module): emit '' visibility + // → a tentative definition that `main()` then fills, mirroring the non-cache + // build. Cached modules keep referencing them `extern`. + program_owned_argv_global := g.pref.build_mode != .build_module + && field.name in ['g_main_argc', 'g_main_argv'] + field_visibility_kw := if field.is_extern { + '' + } else if program_owned_argv_global { + '' + } else { + visibility_kw + } + // `extern ` visibility + `!should_init` means this global is *defined* (and + // initialized) in some OTHER translation unit — its owning module's cached + // object — while the current TU only references it. (`should_init` is true in + // the owning module's own build_module compile, where `extern T x = {..}` is + // the intended single definition.) When merely referencing, emit the + // declaration only: an initializer here is wrong — `extern T x = {..}` is + // itself a definition in C → duplicate symbol at link (notably hit by + // fixed-array globals, whose init is forced inline below, e.g. vgc_*/autostr + // globals defined into every cached module that touches builtin), and a + // `_vinit` initializer would redundantly re-init the cached definition. + global_defined_elsewhere := field_visibility_kw == 'extern ' && !should_init mut qualifiers := '' if field.is_const { qualifiers += 'const ' @@ -615,7 +661,9 @@ fn (mut g Gen) global_decl(node ast.GlobalDecl) { def_builder.write_string('${extern}${tls_kw}${field_visibility_kw}${qualifiers}${styp} ${attributes}${final_c_name}') needs_ending_semicolon = true } - if field.has_expr || cinit { + if global_defined_elsewhere { + // Declaration only — the owning cached object defines + initializes it. + } else if field.has_expr || cinit { // `__global x = unsafe { nil }` should still use the simple direct initialisation, `g_main_argv` needs it. mut is_simple_unsafe_expr := false if field.expr is ast.UnsafeExpr { diff --git a/vlib/v/gen/c/embed.v b/vlib/v/gen/c/embed.v index e7c57bb49e510e..0ffa65134dd4b9 100644 --- a/vlib/v/gen/c/embed.v +++ b/vlib/v/gen/c/embed.v @@ -88,7 +88,10 @@ fn (mut g Gen) gen_embedded_metadata() { if g.pref.parallel_cc { g.extern_out.writeln('extern v__embed_file__EmbedFileData _v_embed_file_metadata(u64 ef_hash);') } - g.embedded_data.writeln('v__embed_file__EmbedFileData _v_embed_file_metadata(u64 ef_hash) {') + // Under -usecache each object that embeds files generates its own copy of this + // function, so external linkage collides at link — emit it file-local. + emb_sc := if g.pref.use_cache || g.pref.build_mode == .build_module { 'static ' } else { '' } + g.embedded_data.writeln('${emb_sc}v__embed_file__EmbedFileData _v_embed_file_metadata(u64 ef_hash) {') g.embedded_data.writeln('\tv__embed_file__EmbedFileData res;') g.embedded_data.writeln('\tmemset(&res, 0, sizeof(res));') g.embedded_data.writeln('\tswitch(ef_hash) {') diff --git a/vlib/v/gen/c/fn.v b/vlib/v/gen/c/fn.v index 028b7aa62a6ff5..29475bbd09fcdf 100644 --- a/vlib/v/gen/c/fn.v +++ b/vlib/v/gen/c/fn.v @@ -1010,6 +1010,22 @@ fn (mut g Gen) fn_decl(node ast.FnDecl) { if g.is_builtin_mod && g.module_built == 'builtin' && node.mod == 'builtin' { skip = false } + // `node.mod` is mis-attributed to the module being built for some + // interface-implementor methods pulled in via the name_table (e.g. os/io + // `NotExpected.code`), so the check above fails to skip them and their body + // is emitted into this cached object as well as the owner's -> duplicate + // symbol. The source FILE's declared module is reliable; if it names another + // module, this body belongs to that module's cached object -> skip it here. + if !skip && node.is_method && node.generic_names.len == 0 && !should_bundle_module + && !g.is_builtin_mod && node.source_file != unsafe { nil } { + // Skip only in non-builtin builds: the builtin object bundles several + // low-level modules (strings, strconv, ...) as its own definitions, so a + // file_mod != 'builtin' there is expected and must NOT be skipped. + file_mod := node.source_file.mod.name + if file_mod != '' && file_mod != g.module_built && file_mod != module_built_short { + skip = true + } + } if !skip && g.pref.is_verbose { println('build module `${g.module_built}` fn `${node.name}`') } @@ -1017,7 +1033,17 @@ fn (mut g Gen) fn_decl(node ast.FnDecl) { if g.pref.use_cache { // We are using prebuilt modules, we do not need to generate // their functions in main.c. - if node.mod != 'main' && node.mod != 'help' && !should_bundle_module && !g.pref.is_test + // The body lives in the cached `.o` for every non-main/non-bundled module + // (builtin + imports + each non-main `_test.v` module — see + // Builder.handle_usecache), so the program TU emits only the declaration; + // re-emitting the body would duplicate the symbol at link. EXCEPTION: + // functions defined in a `_test.v` file — `v build-module` does not compile + // `_test.v`, so those bodies exist ONLY in the program TU. (This used to be a + // blanket `!is_test` over the whole compile, which made test builds re-emit + // every used body — duplicating the tested module's own cached `.o` and + // bloating the program TU so caching saved nothing.) + fn_in_test_file := node.file.ends_with('_test.v') + if node.mod != 'main' && node.mod != 'help' && !should_bundle_module && !fn_in_test_file && node.generic_names.len == 0 { skip = true } @@ -1430,8 +1456,13 @@ fn (mut g Gen) gen_fn_decl(node &ast.FnDecl, skip bool) { } } arg_str := g.out.after(arg_start_pos) + // Bundled modules (util.bundle_modules, e.g. builtin.closure) are skipped in + // EVERY build-module compile, so no cached object carries their bodies; the + // program TU must emit them even though they are `is_builtin` — otherwise e.g. + // builtin__closure__closure_create_with_data is undefined at link for any + // non-test -usecache build that uses closures. if node.no_body || ((g.pref.use_cache && g.pref.build_mode != .build_module) && node.is_builtin - && !g.pref.is_test) || skip { + && !g.pref.is_test && !util.should_bundle_module(node.mod)) || skip { // Just a function header. Builtin function bodies are defined in builtin.o g.definitions.writeln(');') // NO BODY') if g.inside_c_extern { diff --git a/vlib/v/gen/c/infix.v b/vlib/v/gen/c/infix.v index 353202f57b04fd..e2c91e81902604 100644 --- a/vlib/v/gen/c/infix.v +++ b/vlib/v/gen/c/infix.v @@ -1344,7 +1344,14 @@ fn (mut g Gen) infix_expr_is_op(node ast.InfixExpr) { } else if node.right is ast.TypeNode { g.write_type_tag_expr_for_is_left(node, is_aggregate, is_orig_sumtype) g.write(' ${cmp_op} ') - g.write('${int(right_type)}') + // Use type_sidx, not a raw `int(right_type)` literal: under -usecache the + // type tag `_typ` is assigned via the unified `_v_type_idx_()` functions + // (the program TU's numbering; build_module objects reference them extern). + // A raw literal here is the module's LOCAL type index, which can differ + // from that unified value across cached objects, so `x is T` would silently + // disagree with how the value was tagged. In a plain build type_sidx is + // exactly `int(right_type)`, so this is a no-op there. + g.write('${g.type_sidx(right_type)}') } else { g.write_type_tag_expr_for_is_left(node, is_aggregate, is_orig_sumtype) g.write(' ${cmp_op} ') diff --git a/vlib/v/markused/markused.v b/vlib/v/markused/markused.v index 98035c36f43663..132b3670bb3016 100644 --- a/vlib/v/markused/markused.v +++ b/vlib/v/markused/markused.v @@ -281,7 +281,21 @@ pub fn mark_used(mut table ast.Table, mut pref_ pref.Preferences, ast_files []&a walker.mark_generic_types() if pref_.use_cache { - walker.mark_by_sym_name('IError') + // Under -usecache the program TU keeps skip_unused ON (small/fast MAIN) while + // cached module objects are built with it OFF. The program TU still emits the + // interface tables (index + name_table + cast wrappers) for every interface, + // and those wrappers reference each implementor's concrete methods (e.g. + // net__TcpConn_addr). If skip_unused prunes a method the program TU doesn't call + // directly, the wrapper references an undeclared function → C error. Mark every + // non-generic interface (and thus its implementor methods) used so the program + // TU declares them. Bodies are NOT duplicated: cached-module function bodies are + // emitted declaration-only in the program TU (see gen_fn_decl). (Previously only + // IError was marked, which covered builtin error handling but no other interface.) + for isym in table.type_symbols { + if isym.info is ast.Interface && !isym.info.is_generic { + walker.mark_by_sym(isym) + } + } } walker.mark_root_fns(all_fn_root_names) From ab1cf5d9f3e791c42914e3645a24d16d42a15a1b Mon Sep 17 00:00:00 2001 From: eptx Date: Fri, 3 Jul 2026 07:24:53 -0600 Subject: [PATCH 3/7] =?UTF-8?q?builder,vcache:=20sound=20-usecache=20inval?= =?UTF-8?q?idation=20=E2=80=94=20no=20stale-object=20poisoning,=20compiler?= =?UTF-8?q?-identity=20salt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- vlib/v/builder/rebuilding.v | 54 +++++++++++++++++++++++++++++++------ vlib/v/vcache/vcache.v | 15 ++++++++++- 2 files changed, 60 insertions(+), 9 deletions(-) diff --git a/vlib/v/builder/rebuilding.v b/vlib/v/builder/rebuilding.v index 87b68015e10794..09805c9b7ee1a2 100644 --- a/vlib/v/builder/rebuilding.v +++ b/vlib/v/builder/rebuilding.v @@ -27,19 +27,54 @@ pub fn (mut b Builder) rebuild_modules() { $if trace_invalidations ? { eprintln('> rebuild_modules all_files: ${all_files}') } - invalidations := b.find_invalidated_modules_by_files(all_files) + invalidations, snew_hashes := b.find_invalidated_modules_by_files(all_files) $if trace_invalidations ? { eprintln('> rebuild_modules invalidations: ${invalidations}') } + mut stale_object_might_survive := false if invalidations.len > 0 { vexe := pref.vexe_path() for imp in invalidations { - b.v_build_module(vexe, imp) + rc := b.v_build_module(vexe, imp) + if rc != 0 { + // CACHE-POISONING GUARD: a failed module rebuild used to be + // silently ignored while the new source hashes were ALREADY saved — + // every later build then saw "hashes unchanged", skipped invalidation, + // and linked the STALE cached object forever (until a manual cache + // wipe). Observed as a permanent all-tests link failure + // (_builtin__init_consts unresolved); an ABI-compatible variant would + // be a silently WRONG binary. Aborting outright is too strict — the + // invalidator sometimes names paths that are not real buildable + // modules (e.g. a folder of standalone _test.v files) whose rebuild + // has always failed harmlessly and which have no cached object to go + // stale. The precise guard: a failure is only dangerous if a stale + // object could still be SERVED — so remove the module's cached object; + // once nothing stale can be linked, recording the new source hashes is + // safe. Only if the stale object cannot be removed do we skip the hash + // save (forcing re-detection + retry on the next run). A genuinely + // needed-but-missing object is rebuilt on demand by + // rebuild_cached_module, which fails loudly. + stale_o := b.pref.cache_manager.mod_postfix_with_key2cpath(imp, '.o', imp) + if os.exists(stale_o) { + os.rm(stale_o) or { stale_object_might_survive = true } + eprintln('warning: `v build-module ${imp}` failed (exit code ${rc}); removed its cached object so nothing stale can be linked.') + } else { + vcache.dlog('| Builder.' + @FN, + 'build-module failed for ${imp} (rc=${rc}); no cached object under this key — harmless (not a servable module)') + } + } } } + if !stale_object_might_survive { + // Persist the new source hashes only now — after every invalidated module was + // rebuilt (or its stale object provably removed) — so a failed rebuild can + // never poison the cache state. + mut cm := vcache.new_cache_manager(all_files) + cm.save('.hashes', 'all_files', snew_hashes) or {} + } } -pub fn (mut b Builder) find_invalidated_modules_by_files(all_files []string) []string { +pub fn (mut b Builder) find_invalidated_modules_by_files(all_files []string) ([]string, string) { util.timing_start('${@METHOD} source_hashing') mut new_hashes := map[string]string{} mut old_hashes := map[string]string{} @@ -72,7 +107,9 @@ pub fn (mut b Builder) find_invalidated_modules_by_files(all_files []string) []s // eprintln('new_hashes: ${new_hashes}') // eprintln('> new_hashes != old_hashes: ' + ( old_hashes != new_hashes ).str()) // eprintln(snew_hashes) - cm.save('.hashes', 'all_files', snew_hashes) or {} + // NOTE: the new hashes are deliberately NOT saved here. The caller + // saves them only after every invalidated module has been rebuilt + // successfully; saving eagerly poisoned the cache on any rebuild failure. util.timing_measure('${@METHOD} source_hashing') mut invalidations := []string{} @@ -182,10 +219,10 @@ pub fn (mut b Builder) find_invalidated_modules_by_files(all_files []string) []s } util.timing_measure('${@METHOD} rebuilding') } - return invalidations + return invalidations, snew_hashes } -fn (mut b Builder) v_build_module(vexe string, imp_path string) { +fn (mut b Builder) v_build_module(vexe string, imp_path string) int { pwd := os.getwd() defer { os.chdir(pwd) or {} @@ -200,8 +237,9 @@ fn (mut b Builder) v_build_module(vexe string, imp_path string) { $if trace_v_build_module ? { eprintln('> Builder.v_build_module: ${rebuild_cmd}') } - // eprintln('> Builder.v_build_module: ${rebuild_cmd}') - os.system(rebuild_cmd) + // The exit status is the caller's problem now: ignoring it while + // the hashes were saved eagerly permanently poisoned the module cache. + return os.system(rebuild_cmd) } fn (mut b Builder) rebuild_cached_module(vexe string, imp_path string) string { diff --git a/vlib/v/vcache/vcache.v b/vlib/v/vcache/vcache.v index d80524afe44911..29cae9ad724a97 100644 --- a/vlib/v/vcache/vcache.v +++ b/vlib/v/vcache/vcache.v @@ -74,7 +74,20 @@ pub fn new_cache_manager(opts []string) CacheManager { deduped_opts_keys := deduped_opts.keys().filter(it != '' && !it.starts_with("['gcboehm', ")) // TODO: do not filter the gcboehm options here, instead just start `v build-module vlib/builtin` without the -d gcboehm etc. // Note: the current approach of filtering the gcboehm keys may interfere with (potential) other gc modes. - original_vopts := deduped_opts_keys.join('|') + // COMPILER-IDENTITY SALT: cached objects encode the *generating + // compiler's* codegen/ABI. Reusing them across a compiler rebuild (`v self`, + // make) silently links objects from an older cgen — the observed failure was + // `_builtin__init_consts` unresolved after a compiler upgrade, and a + // compatible-ABI variant of it would be a silently WRONG binary. The baked + // git hash is not enough (a `v self` from uncommitted changes keeps it), so + // salt with the running v executable's size+mtime, which changes on every + // rebuild. Each compiler build therefore gets its own cache namespace. + vexe_ident := os.getenv_opt('VEXE') or { os.executable() } + mut vexe_salt := '' + if vexe_ident != '' && os.exists(vexe_ident) { + vexe_salt = 'vexe:${os.file_size(vexe_ident)}:${os.file_last_mod_unix(vexe_ident)}' + } + original_vopts := deduped_opts_keys.join('|') + '|' + vexe_salt return CacheManager{ basepath: vcache_basepath vopts: original_vopts From 4c8c5ffe01deeb71338a794bc75cf82ffe589b25 Mon Sep 17 00:00:00 2001 From: eptx Date: Sat, 4 Jul 2026 16:13:16 -0600 Subject: [PATCH 4/7] tests: add multi-module -usecache regression test (#27592) 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. --- .../testdata/usecache_multi_module/main.v | 27 ++++++++ .../usecache_multi_module/mymod/mymod.v | 69 +++++++++++++++++++ .../usecache_multi_module/tests/uc_test.v | 32 +++++++++ .../testdata/usecache_multi_module/v.mod | 3 + vlib/v/tests/usecache_multi_module_test.v | 66 ++++++++++++++++++ 5 files changed, 197 insertions(+) create mode 100644 vlib/v/tests/testdata/usecache_multi_module/main.v create mode 100644 vlib/v/tests/testdata/usecache_multi_module/mymod/mymod.v create mode 100644 vlib/v/tests/testdata/usecache_multi_module/tests/uc_test.v create mode 100644 vlib/v/tests/testdata/usecache_multi_module/v.mod create mode 100644 vlib/v/tests/usecache_multi_module_test.v diff --git a/vlib/v/tests/testdata/usecache_multi_module/main.v b/vlib/v/tests/testdata/usecache_multi_module/main.v new file mode 100644 index 00000000000000..b2e28013072545 --- /dev/null +++ b/vlib/v/tests/testdata/usecache_multi_module/main.v @@ -0,0 +1,27 @@ +module main + +import mymod + +fn main() { + // sumtype value tagged inside the cached module, `is`-checked in the program TU + s := mymod.make_circle(3) + assert s is mymod.Circle + // `is`-check inside the cached module on a program-TU-held value + assert mymod.classify(s) == 'circle' + // interface built in the cached module, dispatched from both TUs + sp := mymod.make_speaker() + assert sp.speak() == 'woof' + assert mymod.greet(sp) == 'woof' + assert mymod.speaker_type(sp).len > 0 + // closures created in the cached module, called from both TUs + g := mymod.make_getter(42) + assert g() == 42 + assert mymod.call_getter_here(7) == 7 + // module consts + array growth (max_int const) inside the cached module + assert mymod.push_many() == 10 + assert mymod.big_cap == 1000 + // float formatting reads strconv's const tables inside the cached builtin object + assert '${2.5 + 0.25}' == '2.75' + assert 12345.678.str() == '12345.678' + println('OK') +} diff --git a/vlib/v/tests/testdata/usecache_multi_module/mymod/mymod.v b/vlib/v/tests/testdata/usecache_multi_module/mymod/mymod.v new file mode 100644 index 00000000000000..0afa8d5240e192 --- /dev/null +++ b/vlib/v/tests/testdata/usecache_multi_module/mymod/mymod.v @@ -0,0 +1,69 @@ +module mymod + +pub const big_cap = 1000 + +pub struct Circle { +pub: + r int +} + +pub struct Square { +pub: + s int +} + +pub type Shape = Circle | Square + +pub fn make_circle(r int) Shape { + return Circle{r} +} + +pub fn classify(s Shape) string { + if s is Circle { + return 'circle' + } + return 'other' +} + +pub interface Speaker { + speak() string +} + +pub struct Dog { + x int +} + +pub fn (d Dog) speak() string { + return 'woof' +} + +pub fn make_speaker() Speaker { + return Dog{1} +} + +pub fn greet(s Speaker) string { + return s.speak() +} + +pub fn speaker_type(s Speaker) string { + return typeof(s).name +} + +pub fn make_getter(x int) fn () int { + return fn [x] () int { + return x + } +} + +pub fn call_getter_here(x int) int { + g := make_getter(x) + return g() +} + +pub fn push_many() int { + mut a := []int{cap: 2} + for i in 0 .. 10 { + a << i + } + return a.len +} diff --git a/vlib/v/tests/testdata/usecache_multi_module/tests/uc_test.v b/vlib/v/tests/testdata/usecache_multi_module/tests/uc_test.v new file mode 100644 index 00000000000000..fd1e7c0a2151e3 --- /dev/null +++ b/vlib/v/tests/testdata/usecache_multi_module/tests/uc_test.v @@ -0,0 +1,32 @@ +import mymod + +fn test_sumtype_is_across_tus() { + // sumtype value tagged inside the cached module, `is`-checked here + s := mymod.make_circle(3) + assert s is mymod.Circle + // `is`-check inside the cached module on a value held here + assert mymod.classify(s) == 'circle' +} + +fn test_interface_dispatch_across_tus() { + sp := mymod.make_speaker() + assert sp.speak() == 'woof' + assert mymod.greet(sp) == 'woof' + assert mymod.speaker_type(sp).len > 0 +} + +fn test_closures_created_in_cached_module() { + g := mymod.make_getter(42) + assert g() == 42 + assert mymod.call_getter_here(7) == 7 +} + +fn test_module_consts_and_array_growth() { + assert mymod.push_many() == 10 + assert mymod.big_cap == 1000 +} + +fn test_float_formatting_reads_cached_const_tables() { + assert '${2.5 + 0.25}' == '2.75' + assert 12345.678.str() == '12345.678' +} diff --git a/vlib/v/tests/testdata/usecache_multi_module/v.mod b/vlib/v/tests/testdata/usecache_multi_module/v.mod new file mode 100644 index 00000000000000..71f125e70f8343 --- /dev/null +++ b/vlib/v/tests/testdata/usecache_multi_module/v.mod @@ -0,0 +1,3 @@ +Module { + name: 'usecache_multi_module' +} diff --git a/vlib/v/tests/usecache_multi_module_test.v b/vlib/v/tests/usecache_multi_module_test.v new file mode 100644 index 00000000000000..0ab76246845c54 --- /dev/null +++ b/vlib/v/tests/usecache_multi_module_test.v @@ -0,0 +1,66 @@ +// vtest build: !windows +import os + +const vexe = @VEXE +const fixture_dir = os.join_path(@VMODROOT, 'vlib', 'v', 'tests', 'testdata', + 'usecache_multi_module') +const root = os.join_path(os.vtmp_dir(), 'v_usecache_multi_module_${os.getpid()}') +const proj_dir = os.join_path(root, 'proj') +const cache_dir = os.join_path(root, 'cache') +const tmp_dir = os.join_path(root, 'vtmp') + +// Regression test for https://github.com/vlang/v/issues/27592 and the wider +// family of -usecache correctness bugs it exposed. +// +// With -usecache, the program TU is compiled with skip_unused ON while each +// cached module (built via `v build-module`) is compiled with skip_unused OFF, +// so the two used to disagree about what is defined vs referenced (duplicate +// or undefined symbols at link time) and about sum type index numbering +// (`x is T` silently misdispatching across TUs). Cached modules also carried +// their consts as zeroed per-TU statics (e.g. `max_int == 0` inside builtin.o, +// making `array.push` panic on any growth), and closures created inside a +// cached module ran on an uninitialized trampoline pool. +// +// The fixture is a small multi-module project exercising, across the +// program-TU/cached-module boundary: sumtype `is`, interface dispatch, +// closures, module consts / array growth, and float formatting (strconv's +// const tables inside the cached builtin object). It is compiled here as a +// normal program and as a test build, each with a cold and a warm cache. +fn testsuite_begin() { + os.setenv('VCOLORS', 'never', true) + // A regression would surface as a C error; fail fast instead of invoking + // the bug report machinery. + os.setenv('V_C_ERROR_BUG_REPORT_DISABLED', '1', true) + os.rmdir_all(root) or {} + os.mkdir_all(cache_dir) or { panic(err) } + os.mkdir_all(tmp_dir) or { panic(err) } + os.cp_all(fixture_dir, proj_dir, true) or { panic(err) } + // Isolate the module cache, so cold vs warm behavior is deterministic and + // the user's real cache is left untouched. + os.setenv('VCACHE', cache_dir, true) + os.setenv('VTMP', tmp_dir, true) +} + +fn testsuite_end() { + os.rmdir_all(root) or {} +} + +fn test_usecache_program_cold_and_warm() { + for label in ['cold', 'warm'] { + res := os.execute('${os.quoted_path(vexe)} -usecache run ${os.quoted_path(proj_dir)}') + assert res.exit_code == 0, '${label} -usecache run failed:\n${res.output}' + assert res.output.trim_space() == 'OK', '${label} -usecache run output:\n${res.output}' + } +} + +fn test_usecache_test_build_cold_and_warm() { + // Start from a fresh cache, so the test-build path is also exercised cold + // (it caches the modules of the _test.v file itself, in addition to the + // imported ones). + os.rmdir_all(cache_dir) or {} + os.mkdir_all(cache_dir) or { panic(err) } + for label in ['cold', 'warm'] { + res := os.execute('${os.quoted_path(vexe)} -usecache test ${os.quoted_path(proj_dir)}') + assert res.exit_code == 0, '${label} -usecache test failed:\n${res.output}' + } +} From 8184f8cf52c526d1d53c72c7c57d783218aea626 Mon Sep 17 00:00:00 2001 From: eptx Date: Sat, 4 Jul 2026 21:39:13 -0600 Subject: [PATCH 5/7] cgen: no cross-TU tentative definitions under -usecache/build_module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- vlib/v/gen/c/cgen.v | 13 +++++++++++-- vlib/v/gen/c/consts_and_globals.v | 28 ++++++++++++++++++++++++---- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/vlib/v/gen/c/cgen.v b/vlib/v/gen/c/cgen.v index 9a053ac6e30173..7b869f543ff695 100644 --- a/vlib/v/gen/c/cgen.v +++ b/vlib/v/gen/c/cgen.v @@ -14237,16 +14237,25 @@ fn (mut g Gen) interface_table() string { // Exclude interface types from the table length — an interface can't // be its own concrete implementor (happens with nested generic interfaces). iname_table_length := impl_types.filter(g.table.sym(it).kind !in [.interface, .aggregate]).len + // A cached module object must only *declare* the name table — the program + // TU owns the single initialized definition. The historic form (a tentative + // `struct x name_table[N];` in every TU, merged as a common symbol at link) + // no longer links: current Apple ld and lld reject cross-TU tentative + // definitions as duplicate symbols instead of merging them. if iname_table_length == 0 { // msvc can not process `static struct x[0] = {};` - methods_struct.writeln('${g.static_modifier}${methods_struct_name} ${interface_name}_name_table[1];') + if g.pref.build_mode == .build_module { + methods_struct.writeln('extern ${methods_struct_name} ${interface_name}_name_table[1];') + } else { + methods_struct.writeln('${g.static_modifier}${methods_struct_name} ${interface_name}_name_table[1];') + } } else { name_table_len := iname_table_length + 1 if g.pref.build_mode != .build_module { methods_struct.writeln('${g.static_modifier}${methods_struct_name} ${interface_name}_name_table[${name_table_len}] = {') methods_struct.writeln('\t{0},') } else { - methods_struct.writeln('${g.static_modifier}${methods_struct_name} ${interface_name}_name_table[${name_table_len}];') + methods_struct.writeln('extern ${methods_struct_name} ${interface_name}_name_table[${name_table_len}];') } } mut cast_functions := strings.new_builder(100) diff --git a/vlib/v/gen/c/consts_and_globals.v b/vlib/v/gen/c/consts_and_globals.v index 78ad85ae0ba38b..3cc5ffe657e4e6 100644 --- a/vlib/v/gen/c/consts_and_globals.v +++ b/vlib/v/gen/c/consts_and_globals.v @@ -511,11 +511,19 @@ fn (mut g Gen) global_decl(node ast.GlobalDecl) { // "Undefined symbols" failure). Every other module either owns a cached object // (defined there, `extern` here) or is bundled (already excluded below). is_program_module_global := g.pref.build_mode != .build_module && node.mod == 'main' + // A bundled module (builtin.closure, builtin.overflow, ...) is compiled into + // the program TU only, never as its own cached object — so the program TU owns + // its globals and every build_module compile must reference them `extern`. The + // historic tentative definition per TU relied on the linker merging common + // symbols, which current Apple ld and lld no longer do (duplicate-symbol error). + is_bundled_mod := util.should_bundle_module(node.mod) visibility_kw := if g.should_use_object_local_linkage(node.mod) { 'static ' + } else if g.pref.build_mode == .build_module && is_bundled_mod { + 'extern ' } else if (g.pref.use_cache || (g.pref.build_mode == .build_module && g.module_built != node.mod)) - && !util.should_bundle_module(node.mod) && !is_program_module_global { + && !is_bundled_mod && !is_program_module_global { 'extern ' } else { '' @@ -532,6 +540,9 @@ fn (mut g Gen) global_decl(node ast.GlobalDecl) { should_init := (!g.pref.use_cache && g.pref.build_mode != .build_module) || (g.pref.build_mode == .build_module && g.module_built == node.mod) || is_program_module_global + // bundled-module globals: the program TU is their single definition site + // (cached objects reference them `extern` — see visibility_kw above) + || (g.pref.use_cache && g.pref.build_mode != .build_module && is_bundled_mod) mut attributes := '' first_field := node.fields[0] if first_field.is_weak { @@ -569,12 +580,17 @@ fn (mut g Gen) global_decl(node ast.GlobalDecl) { // Define them in the program TU (build_mode != build_module): emit '' visibility // → a tentative definition that `main()` then fills, mirroring the non-cache // build. Cached modules keep referencing them `extern`. - program_owned_argv_global := g.pref.build_mode != .build_module - && field.name in ['g_main_argc', 'g_main_argv'] + is_argv_global := field.name in ['g_main_argc', 'g_main_argv'] + program_owned_argv_global := g.pref.build_mode != .build_module && is_argv_global field_visibility_kw := if field.is_extern { '' } else if program_owned_argv_global { '' + } else if g.pref.build_mode == .build_module && is_argv_global { + // builtin declares these, but the program TU owns them (its `main()` + // assigns them) — builtin's own cached object must not emit the + // historic tentative definition (no longer merged at link, see above). + 'extern ' } else { visibility_kw } @@ -588,7 +604,11 @@ fn (mut g Gen) global_decl(node ast.GlobalDecl) { // fixed-array globals, whose init is forced inline below, e.g. vgc_*/autostr // globals defined into every cached module that touches builtin), and a // `_vinit` initializer would redundantly re-init the cached definition. - global_defined_elsewhere := field_visibility_kw == 'extern ' && !should_init + // The argv globals are declared by builtin but owned by the program TU even + // in builtin's own build_module compile (should_init is true there, which + // would otherwise turn the `extern` into `extern ... = 0;` — a definition). + global_defined_elsewhere := (field_visibility_kw == 'extern ' && !should_init) + || (g.pref.build_mode == .build_module && is_argv_global) mut qualifiers := '' if field.is_const { qualifiers += 'const ' From 2ad1a2a9f1b8edca9209967d3b544434ff27ae70 Mon Sep 17 00:00:00 2001 From: eptx Date: Sun, 5 Jul 2026 10:23:52 -0600 Subject: [PATCH 6/7] checker,builder: deterministic $embed_file resolution + embedded-asset cache invalidation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- vlib/v/ast/ast.v | 2 + vlib/v/builder/cc.v | 5 ++ vlib/v/builder/rebuilding.v | 80 ++++++++++++++++++++++--- vlib/v/checker/comptime.v | 47 +++++++++++++-- vlib/v/parser/comptime.v | 1 + vlib/v/tests/usecache_embed_file_test.v | 62 +++++++++++++++++++ 6 files changed, 183 insertions(+), 14 deletions(-) create mode 100644 vlib/v/tests/usecache_embed_file_test.v diff --git a/vlib/v/ast/ast.v b/vlib/v/ast/ast.v index 79a727ef0af974..0ebea251c88676 100644 --- a/vlib/v/ast/ast.v +++ b/vlib/v/ast/ast.v @@ -1123,6 +1123,7 @@ pub mut: pub struct EmbeddedFile { pub: compression_type string + source_file string // path of the .v file containing the $embed_file call; the anchor for relative-path resolution (a const may be checked from an importing file's context, so Checker.file is not reliable here) pub mut: rpath string // used in the source code, as an ID/key to the embed apath string // absolute path during compilation to the resource @@ -1165,6 +1166,7 @@ pub mut: used_imports []string implied_imports []string // ​imports that the user's code uses but omitted to import explicitly, used by `vfmt` embedded_files []EmbeddedFile // list of files to embed in the binary + embedded_apaths []string // absolute paths of $embed_file targets (deduped, checker-resolved); drives -usecache invalidation of embedded assets imported_symbols map[string]string // used for `import {symbol}`, it maps symbol => module.symbol imported_symbols_trie token.KeywordsMatcherTrie // constructed from imported_symbols, to accelerate presense checks imported_symbols_used map[string]bool diff --git a/vlib/v/builder/cc.v b/vlib/v/builder/cc.v index 97e3c03879368b..bfd2d2044c7752 100644 --- a/vlib/v/builder/cc.v +++ b/vlib/v/builder/cc.v @@ -1321,6 +1321,11 @@ fn (mut v Builder) setup_output_name() { get_dsc_content('PREF.PATH: ${v.pref.path}\nVOPTS: ${v.pref.cache_manager.vopts}\n')) or { panic(err) } + // Record the content hashes of every $embed_file target that went into this + // cached object, so rebuild_cached_module can invalidate it when an embedded + // asset changes (embedded assets are invisible to the .v source hashes). + v.pref.cache_manager.mod_save(v.pref.path, '.embeds.txt', v.pref.path, + embeds_manifest(v.parsed_files)) or { panic(err) } } if os.is_dir(v.pref.out_name) { verror('${os.quoted_path(v.pref.out_name)} is a directory') diff --git a/vlib/v/builder/rebuilding.v b/vlib/v/builder/rebuilding.v index 09805c9b7ee1a2..f547c321ae19de 100644 --- a/vlib/v/builder/rebuilding.v +++ b/vlib/v/builder/rebuilding.v @@ -242,18 +242,82 @@ fn (mut b Builder) v_build_module(vexe string, imp_path string) int { return os.system(rebuild_cmd) } +// embed_file_content_hash returns the invalidation hash for one embedded-asset +// path; an unreadable file hashes to a constant that can never match a real +// content hash, so it always invalidates. +fn embed_file_content_hash(apath string) string { + econtent := util.read_file(apath) or { return 'unreadable' } + return hash.sum64_string(econtent, 7).hex_full() +} + +// embeds_manifest serialises every checker-resolved $embed_file target of +// `parsed_files` as ` ` lines. Saved next to a +// cached module object (see setup_output_name); verified by +// cached_module_embeds_fresh before a cached object is served. +fn embeds_manifest(parsed_files []&ast.File) string { + mut seen := map[string]bool{} + mut lines := []string{} + for pf in parsed_files { + for ea in pf.embedded_apaths { + if seen[ea] { + continue + } + seen[ea] = true + lines << '${embed_file_content_hash(ea)} ${ea}' + } + } + lines.sort() + return lines.join('\n') +} + +// cached_module_embeds_fresh reports whether every embedded asset recorded in +// the module's .embeds.txt manifest still has the content it was built with. +// The .v source hashes cannot see $embed_file targets, so without this check a +// cached object silently keeps serving stale embedded data after the asset +// changes. A missing manifest means the object was built by this compiler +// build with no embeds recorded (the compiler-identity salt namespaces away +// pre-manifest objects), so it is trivially fresh. +fn (mut b Builder) cached_module_embeds_fresh(imp_path string) bool { + manifest := b.pref.cache_manager.mod_load(imp_path, '.embeds.txt', imp_path) or { return true } + for line in manifest.split_into_lines() { + if line == '' { + continue + } + recorded := line.all_before(' ') + apath := line.all_after(' ') + if embed_file_content_hash(apath) != recorded { + vcache.dlog('| Builder.' + @FN, 'stale embedded asset: ${apath} (module ${imp_path})') + return false + } + } + return true +} + fn (mut b Builder) rebuild_cached_module(vexe string, imp_path string) string { - res := b.pref.cache_manager.mod_exists(imp_path, '.o', imp_path) or { - if b.pref.is_verbose { + mut existing := '' + if res := b.pref.cache_manager.mod_exists(imp_path, '.o', imp_path) { + existing = res + } + if existing != '' && b.cached_module_embeds_fresh(imp_path) { + return existing + } + if b.pref.is_verbose { + if existing == '' { println('Cached ${imp_path} .o file not found... Building .o file for ${imp_path}') + } else { + println('Cached ${imp_path} .o file has stale embedded assets... Rebuilding .o file for ${imp_path}') } - b.v_build_module(vexe, imp_path) - rebuilt_o := b.pref.cache_manager.mod_exists(imp_path, '.o', imp_path) or { - panic('could not rebuild cache module for ${imp_path}, error: ${err.msg()}') - } - return rebuilt_o } - return res + rc := b.v_build_module(vexe, imp_path) + if rc != 0 && existing != '' { + // A failed rebuild must not keep serving the stale object (the same + // poisoning class as the eager-hash-save bug, cx #151). + os.rm(existing) or {} + } + rebuilt_o := b.pref.cache_manager.mod_exists(imp_path, '.o', imp_path) or { + panic('could not rebuild cache module for ${imp_path}, error: ${err.msg()}') + } + return rebuilt_o } fn (mut b Builder) handle_usecache(vexe string) { diff --git a/vlib/v/checker/comptime.v b/vlib/v/checker/comptime.v index 5a2efc1ba20f2b..c834a6336f3a67 100644 --- a/vlib/v/checker/comptime.v +++ b/vlib/v/checker/comptime.v @@ -476,11 +476,7 @@ fn (mut c Checker) comptime_call(mut node ast.ComptimeCall) ast.Type { escaped_path = c.resolve_pseudo_variables(escaped_path, node.pos) or { return ast.string_type } - abs_path := os.real_path(escaped_path) - // check absolute path first - if !os.exists(abs_path) { - // ... look relative to the source file: - escaped_path = os.real_path(os.join_path_single(os.dir(c.file.path), escaped_path)) + if os.is_abs_path(escaped_path) { if !os.exists(escaped_path) { c.error('"${escaped_path}" does not exist so it cannot be embedded', node.pos) return ast.string_type @@ -489,11 +485,50 @@ fn (mut c Checker) comptime_call(mut node ast.ComptimeCall) ast.Type { c.error('"${escaped_path}" is not a file so it cannot be embedded', node.pos) return ast.string_type } + escaped_path = os.real_path(escaped_path) } else { - escaped_path = abs_path + // Per the documented semantics ("Paths can be absolute or relative to the + // source file"), a relative path binds to the file next to the SOURCE first. + // Probing the process CWD first made the embedded content depend on where + // the compiler happened to run: the -usecache `v build-module` child runs + // chdir-ed to VROOT, so a CWD-relative twin of the file there silently + // replaced the intended source-relative one in the cached module object, + // while the main program TU embedded the right one. CWD-relative resolution + // stays as a fallback for backwards compatibility. + // The anchor is the file DECLARING the call (recorded by the parser): a + // module's const initializer can be checked from an importing file's + // context, so c.file would be the wrong anchor there. + anchor := if node.embed_file.source_file != '' { + node.embed_file.source_file + } else { + c.file.path + } + src_relative := os.real_path(os.join_path_single(os.dir(anchor), escaped_path)) + if os.exists(src_relative) && os.is_file(src_relative) { + escaped_path = src_relative + } else { + cwd_relative := os.real_path(escaped_path) + if !os.exists(cwd_relative) { + c.error('"${src_relative}" does not exist so it cannot be embedded', + node.pos) + return ast.string_type + } + if !os.is_file(cwd_relative) { + c.error('"${cwd_relative}" is not a file so it cannot be embedded', + node.pos) + return ast.string_type + } + escaped_path = cwd_relative + } } node.embed_file.rpath = raw_path node.embed_file.apath = escaped_path + // Record the resolved target on the file, so the module cache can detect a + // changed embedded asset (invisible to the .v source hashes) — consumed by + // Builder.rebuild_cached_module via the .embeds.txt manifest. + if escaped_path !in c.file.embedded_apaths { + c.file.embedded_apaths << escaped_path + } } // c.file.embedded_files << node.embed_file if node.embed_file.compression_type !in ast.valid_comptime_compression_types { diff --git a/vlib/v/parser/comptime.v b/vlib/v/parser/comptime.v index 32b96902666f35..88aab2e48eb4a9 100644 --- a/vlib/v/parser/comptime.v +++ b/vlib/v/parser/comptime.v @@ -510,6 +510,7 @@ fn (mut p Parser) comptime_call() ast.ComptimeCall { kind: .embed_file embed_file: ast.EmbeddedFile{ compression_type: embed_compression_type + source_file: p.file_path } args: [arg] pos: start_pos.extend(p.prev_tok.pos()) diff --git a/vlib/v/tests/usecache_embed_file_test.v b/vlib/v/tests/usecache_embed_file_test.v new file mode 100644 index 00000000000000..2dc150d32c6e73 --- /dev/null +++ b/vlib/v/tests/usecache_embed_file_test.v @@ -0,0 +1,62 @@ +// Regression test for two -usecache $embed_file defects: +// +// 1. RESOLUTION: a relative embed path used to bind to the process CWD first +// whenever a file existed there, instead of the documented source-relative +// location ("Paths can be absolute or relative to the source file"). The +// -usecache `v build-module` child runs chdir-ed to VROOT, so the cached +// module object could silently embed a different file than the program TU. +// Here the compiler runs from a CWD where a decoy `../asset.txt` exists — +// the embed must still bind to the file next to the source. +// +// 2. INVALIDATION: embedded assets are invisible to the .v source hashes, so +// editing ONLY the embedded file used to keep serving the stale cached +// module object forever. The .embeds.txt manifest must invalidate it. +import os + +const vexe = os.quoted_path(@VEXE) + +fn write_asset(proj string, content string) ! { + os.write_file(os.join_path(proj, 'asset.txt'), content)! + os.write_file(os.join_path(proj, 'expected.txt'), content)! +} + +fn test_usecache_embed_file_source_relative_and_invalidation() { + $if windows { + eprintln('skipping on windows (posix shell used to control the build CWD)') + return + } + tmp := os.join_path(os.vtmp_dir(), 'usecache_embed_file_test_${os.getpid()}') + os.rmdir_all(tmp) or {} + defer { + os.rmdir_all(tmp) or {} + } + proj := os.join_path(tmp, 'proj') + os.mkdir_all(os.join_path(proj, 'mymod'))! + os.write_file(os.join_path(proj, 'v.mod'), "Module {\n\tname: 'proj'\n\tversion: '0.0.1'\n}\n")! + os.write_file(os.join_path(proj, 'mymod', 'mymod.v'), + "module mymod\n\npub const src = \$embed_file('../asset.txt').to_string()\n")! + // the checked program: asserts the EMBEDDED content == the runtime file, + // so a stale or misresolved embed fails the child test run: + os.write_file(os.join_path(proj, 'check_test.v'), + "import os\nimport mymod\n\nfn test_embedded_content_matches_expected() {\n\texpected := os.read_file(os.join_path(os.dir(@FILE), 'expected.txt'))!\n\tassert mymod.src == expected\n}\n")! + write_asset(proj, 'first-content')! + // a decoy asset reachable via the SAME relative path from the build CWD: + decoy_cwd := os.join_path(tmp, 'decoy', 'cwd') + os.mkdir_all(decoy_cwd)! + os.write_file(os.join_path(tmp, 'decoy', 'asset.txt'), 'DECOY')! + // isolated module cache, so the test cannot poison (or be poisoned by) + // the developer's real one: + vcache_dir := os.join_path(tmp, 'vcache') + build_cmd := 'cd ${os.quoted_path(decoy_cwd)} && VCACHE=${os.quoted_path(vcache_dir)} ${vexe} -usecache test ${os.quoted_path(os.join_path(proj, + 'check_test.v'))}' + // + // 1. cold build+run from the decoy CWD — must embed the source-relative asset: + res1 := os.execute(build_cmd) + assert res1.exit_code == 0, 'cold -usecache run embedded the wrong content (CWD-relative decoy?):\n${res1.output}' + // + // 2. edit ONLY the embedded asset (no .v change); the warm cache must + // invalidate the module object and pick the new content up: + write_asset(proj, 'second-content')! + res2 := os.execute(build_cmd) + assert res2.exit_code == 0, 'warm -usecache run served a stale embedded asset:\n${res2.output}' +} From 6bbd24692cf0516ce3ea7bfc7a50773302d400ca Mon Sep 17 00:00:00 2001 From: eptx Date: Sun, 5 Jul 2026 16:05:27 -0600 Subject: [PATCH 7/7] builder,cgen: fix -usecache under VFLAGS environments; keep -is_o argv globals object-local MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- vlib/v/builder/rebuilding.v | 18 +++++++++++++++++- vlib/v/gen/c/consts_and_globals.v | 12 +++++++++--- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/vlib/v/builder/rebuilding.v b/vlib/v/builder/rebuilding.v index f547c321ae19de..6d1b45dadf5f39 100644 --- a/vlib/v/builder/rebuilding.v +++ b/vlib/v/builder/rebuilding.v @@ -237,9 +237,25 @@ fn (mut b Builder) v_build_module(vexe string, imp_path string) int { $if trace_v_build_module ? { eprintln('> Builder.v_build_module: ${rebuild_cmd}') } + // The child must reconstruct EXACTLY the parent's build configuration from + // the relayed b.pref.build_options — the parent already folded VFLAGS into + // those while parsing its own args. Letting the child re-apply VFLAGS on + // top duplicates its values (e.g. `-cflags x` becomes `x x`), so the child + // derives a DIFFERENT vcache key than the one the parent looks up, and the + // freshly built module object is never found ('could not rebuild cache + // module ... does not exist yet' — hit by any -usecache build running + // under a VFLAGS environment, e.g. the sanitized CI jobs). + old_vflags := os.getenv('VFLAGS') + if old_vflags != '' { + os.setenv('VFLAGS', '', true) + } // The exit status is the caller's problem now: ignoring it while // the hashes were saved eagerly permanently poisoned the module cache. - return os.system(rebuild_cmd) + rc := os.system(rebuild_cmd) + if old_vflags != '' { + os.setenv('VFLAGS', old_vflags, true) + } + return rc } // embed_file_content_hash returns the invalidation hash for one embedded-asset diff --git a/vlib/v/gen/c/consts_and_globals.v b/vlib/v/gen/c/consts_and_globals.v index 3cc5ffe657e4e6..4021c74843baea 100644 --- a/vlib/v/gen/c/consts_and_globals.v +++ b/vlib/v/gen/c/consts_and_globals.v @@ -537,12 +537,13 @@ fn (mut g Gen) global_decl(node ast.GlobalDecl) { g.inside_cinit = false g.inside_global_decl = false } + // The last clause: bundled-module globals under -usecache — the program TU is + // their single definition site (cached objects reference them `extern`, see + // visibility_kw above), so it must also emit their initialized definition. should_init := (!g.pref.use_cache && g.pref.build_mode != .build_module) || (g.pref.build_mode == .build_module && g.module_built == node.mod) || is_program_module_global - // bundled-module globals: the program TU is their single definition site - // (cached objects reference them `extern` — see visibility_kw above) - || (g.pref.use_cache && g.pref.build_mode != .build_module && is_bundled_mod) + || (g.pref.use_cache && g.pref.build_mode != .build_module && is_bundled_mod) mut attributes := '' first_field := node.fields[0] if first_field.is_weak { @@ -580,8 +581,13 @@ fn (mut g Gen) global_decl(node ast.GlobalDecl) { // Define them in the program TU (build_mode != build_module): emit '' visibility // → a tentative definition that `main()` then fills, mirroring the non-cache // build. Cached modules keep referencing them `extern`. + // -is_o is excluded: each -is_o output is a standalone hostable TU and keeps + // the object-local (static) linkage for these — several such TUs are expected + // to be linked together (see link_generated_c_files_test.v), so a non-static + // definition in each would collide. is_argv_global := field.name in ['g_main_argc', 'g_main_argv'] program_owned_argv_global := g.pref.build_mode != .build_module && is_argv_global + && !g.should_use_object_local_linkage(node.mod) field_visibility_kw := if field.is_extern { '' } else if program_owned_argv_global {