Skip to content

Commit 213baca

Browse files
committed
leftover elem segments count as table mutation for call_indirect elisions
Active elem segments whose memory range extends past the destination table's current size at instantiation behave as a runtime mutation: the trailing entries get dropped, but only after they've been considered for table-resize semantics. Treat the source table as mutated when the module contains such a segment so the call_indirect elisions don't over-fire. Adds an integration test (`tests/all/leftover_elem_segment_soundness.rs`) + two disas filetests covering the leftover-segment shape.
1 parent e60a43d commit 213baca

8 files changed

Lines changed: 712 additions & 20 deletions

File tree

crates/cranelift/src/func_environ.rs

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2037,10 +2037,16 @@ impl<'a, 'func, 'module_env> Call<'a, 'func, 'module_env> {
20372037
/// All four of these must hold for the resolution to succeed:
20382038
///
20392039
/// 1. The target table must be provably immutable for the lifetime of
2040-
/// any instance of this module: defined (not imported) and never the
2041-
/// target of `table.set` / `table.fill` / `table.copy` (as the dst)
2042-
/// / `table.grow` / `table.init`. This is the `tables_mutated` bit
2043-
/// populated in `ModuleEnvironment::translate`.
2040+
/// any instance of this module: defined (not imported), not exported,
2041+
/// never the target of `table.set` / `table.fill` / `table.copy`
2042+
/// (as the dst) / `table.grow` / `table.init`, and not the target
2043+
/// of any leftover active `elem` segment (one
2044+
/// `try_func_table_init` couldn't fold into the precomputed image
2045+
/// and that will execute at instantiation time, potentially
2046+
/// overwriting precomputed slots with arbitrary funcrefs). All of
2047+
/// these conditions are folded into the `tables_mutated` bit
2048+
/// populated in `ModuleEnvironment::translate` —
2049+
/// `tables_mutated[t] == false` is necessary and sufficient.
20442050
///
20452051
/// 2. The callee index value (the operand to `call_indirect`) must be a
20462052
/// compile-time constant — i.e., the wasm did `i32.const N;
@@ -2140,8 +2146,14 @@ impl<'a, 'func, 'module_env> Call<'a, 'func, 'module_env> {
21402146
/// True when:
21412147
///
21422148
/// 1. The table is provably immutable (`tables_mutated[table_index] ==
2143-
/// false`). Defined-not-imported is implied since imported tables
2144-
/// are pre-marked as mutated.
2149+
/// false`). The `tables_mutated` bit set by
2150+
/// `analyze_table_mutability` covers imports, exports, runtime
2151+
/// mutation opcodes, AND tables targeted by leftover active `elem`
2152+
/// segments (segments that didn't fold into the precomputed image
2153+
/// and execute at instantiation time, potentially overwriting
2154+
/// precomputed slots with a funcref of a different signature).
2155+
/// `false` here is therefore sufficient to treat the precomputed
2156+
/// image as authoritative for the lifetime of any instance.
21452157
///
21462158
/// 2. The table is precomputable from static `elem` segments
21472159
/// (`TableInitialValue::Null { precomputed }`).
@@ -2179,7 +2191,10 @@ impl<'a, 'func, 'module_env> Call<'a, 'func, 'module_env> {
21792191
let Some(defined_table) = module.defined_table_index(table_index) else {
21802192
return false;
21812193
};
2182-
let Some(init) = module.table_initialization.initial_values.get(defined_table)
2194+
let Some(init) = module
2195+
.table_initialization
2196+
.initial_values
2197+
.get(defined_table)
21832198
else {
21842199
return false;
21852200
};
@@ -2205,9 +2220,7 @@ impl<'a, 'func, 'module_env> Call<'a, 'func, 'module_env> {
22052220
return false;
22062221
}
22072222
// Every slot must be a real FuncIndex — no reserved-value sentinels.
2208-
precomputed
2209-
.iter()
2210-
.all(|f| !f.is_reserved_value())
2223+
precomputed.iter().all(|f| !f.is_reserved_value())
22112224
}
22122225

22132226
fn try_elide_sig_check_for_immutable_table(
@@ -2226,7 +2239,11 @@ impl<'a, 'func, 'module_env> Call<'a, 'func, 'module_env> {
22262239
None => return false,
22272240
};
22282241

2229-
let init = match module.table_initialization.initial_values.get(defined_table) {
2242+
let init = match module
2243+
.table_initialization
2244+
.initial_values
2245+
.get(defined_table)
2246+
{
22302247
Some(i) => i,
22312248
None => return false,
22322249
};

crates/environ/src/compile/module_environ.rs

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -259,8 +259,6 @@ impl<'a, 'data> ModuleEnvironment<'a, 'data> {
259259
self.translate_payload(payload?)?;
260260
}
261261

262-
analyze_table_mutability(&mut self.result)?;
263-
264262
// Precompute static funcref-table contents from `elem` segments
265263
// before Cranelift lowering runs. The redundant call later in
266264
// `wasmtime/src/compile.rs::build_module_artifacts` is a no-op
@@ -276,10 +274,21 @@ impl<'a, 'data> ModuleEnvironment<'a, 'data> {
276274
// empty-precomputed check, never firing on real workloads —
277275
// their measured improvements in earlier commit messages were
278276
// measurement noise, not real signal.
277+
//
278+
// Folded segments are removed from `table_initialization
279+
// .segments`; whatever's left is genuinely deferred to runtime.
280+
// We do this BEFORE `analyze_table_mutability` so that the
281+
// mutability analyzer can mark "tables with leftover segments"
282+
// as conservatively mutated — leftover segments overwrite
283+
// precomputed slots at instantiation time and so make the
284+
// precomputed image non-authoritative for downstream call_
285+
// indirect elisions. See `analyze_table_mutability`'s body.
279286
if self.tunables.table_lazy_init {
280287
self.result.try_func_table_init();
281288
}
282289

290+
analyze_table_mutability(&mut self.result)?;
291+
283292
Ok(self.result)
284293
}
285294

@@ -1398,17 +1407,34 @@ impl ModuleTranslation<'_> {
13981407
/// destination, `table.grow`, `table.init`).
13991408
///
14001409
/// Imported tables are conservatively pre-marked as mutated since the
1401-
/// importer can mutate them in ways we can't see. Active `elem` segments
1402-
/// applied at instantiation time are NOT counted as mutations — they are
1403-
/// part of the table's *initial* state, not a runtime change.
1410+
/// importer can mutate them in ways we can't see. Exported tables are
1411+
/// also pre-marked: a host (or another instance importing the export)
1412+
/// can `Table::set` / `Table::grow` them via the public wasmtime API,
1413+
/// and those writes aren't visible in this module's bytecode.
1414+
///
1415+
/// **Active `elem` segments**: only those that `try_func_table_init`
1416+
/// successfully folded into `table_initialization.initial_values[t]
1417+
/// .precomputed` are part of the table's *initial* state and don't
1418+
/// count as mutations. Anything still left in `table_initialization
1419+
/// .segments` after that pass is a *leftover segment* that runs at
1420+
/// instantiation time and can overwrite slots the precomputed image
1421+
/// describes — including writing a different `FuncIndex`, a function
1422+
/// of a different signature, or a null. Downstream `call_indirect`
1423+
/// elisions in `crates/cranelift/src/func_environ.rs`
1424+
/// (`try_static_resolve_indirect_call`,
1425+
/// `try_elide_sig_check_for_immutable_table`,
1426+
/// `precomputed_table_has_no_null_slots`) read directly from
1427+
/// `precomputed`, so leftover segments make those reads
1428+
/// non-authoritative. We mark every such target table as mutated to
1429+
/// kill the elisions on it. (`try_func_table_init` must therefore have
1430+
/// already run when we reach this function — the call site in
1431+
/// `translate` enforces that ordering.)
14041432
///
14051433
/// `elem.drop` drops a passive element segment but does not write to any
14061434
/// table directly, so it is intentionally not counted here. Conservatively,
14071435
/// any `table.init` from a passive segment marks the destination table as
14081436
/// mutated.
1409-
fn analyze_table_mutability<'data>(
1410-
translation: &mut ModuleTranslation<'data>,
1411-
) -> Result<()> {
1437+
fn analyze_table_mutability<'data>(translation: &mut ModuleTranslation<'data>) -> Result<()> {
14121438
// Resize the table-mutability map to cover every table in the module
14131439
// (imports + defined). `SecondaryMap` defaults to `false` for all
14141440
// unset entries, which is the correct "definitely-not-mutated" default
@@ -1471,5 +1497,24 @@ fn analyze_table_mutability<'data>(
14711497
}
14721498
}
14731499

1500+
// Leftover active `elem` segments — anything `try_func_table_init`
1501+
// couldn't fold into `precomputed`. These run at instantiation time
1502+
// *after* the precomputed image has been applied and can overwrite
1503+
// any slot in their target table with arbitrary funcrefs (different
1504+
// signature, null, ...). Mark each target table as mutated so the
1505+
// downstream call_indirect elisions correctly bail out — they all
1506+
// read from `precomputed` and assume it's authoritative for the
1507+
// lifetime of any instance, which leftover segments invalidate.
1508+
//
1509+
// No-op if `try_func_table_init` cleared the segments list, which
1510+
// is the common case for compiler-emitted wasm (constant-offset
1511+
// Functions-form segments). Witness for the bug this guards
1512+
// against: a second elem segment with `(offset (global.get $g))`
1513+
// for the same table — dynamic offsets force the segment to stay
1514+
// in the segments list (see `try_func_table_init`).
1515+
for segment in translation.module.table_initialization.segments.iter() {
1516+
translation.module.tables_mutated[segment.table_index] = true;
1517+
}
1518+
14741519
Ok(())
14751520
}

crates/environ/src/module.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -714,7 +714,14 @@ impl Module {
714714
/// - The table is provably immutable
715715
/// (`tables_mutated[idx] == false`). Imported and exported
716716
/// tables are pre-marked as mutated by
717-
/// `analyze_table_mutability` and so excluded.
717+
/// `analyze_table_mutability` and so excluded. The same pass
718+
/// marks tables with **leftover active `elem` segments**
719+
/// (segments that `try_func_table_init` couldn't fold into the
720+
/// precomputed image and that will run at instantiation time)
721+
/// as mutated, since those segments can overwrite slots after
722+
/// the precomputed image is applied — making any predicate
723+
/// that reads from `precomputed` non-authoritative for the
724+
/// lifetime of an instance.
718725
/// - The table has a precomputed image
719726
/// (`TableInitialValue::Null { precomputed }`, not `Expr`).
720727
/// - The precomputed image covers the full minimum-size range of

crates/environ/tests/table_mutability.rs

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,3 +305,132 @@ fn module_with_no_tables_produces_empty_mutability_vec() {
305305
);
306306
assert!(bits.is_empty(), "no tables ⇒ no mutability bits");
307307
}
308+
309+
// -----------------------------------------------------------------------------
310+
// Leftover active elem segments — soundness gate for the call_indirect
311+
// elisions in `crates/cranelift/src/func_environ.rs`.
312+
//
313+
// `try_func_table_init` folds *some* active `elem` segments into
314+
// `table_initialization.initial_values[t].precomputed` at compile time
315+
// and drains them from `table_initialization.segments`. Any segment it
316+
// can't fold (dynamic offset, `Expressions` form, out-of-range, ...)
317+
// stays in the `segments` list and runs at instantiation time *after*
318+
// the precomputed image is applied — potentially overwriting slots
319+
// that downstream optimizations have read from `precomputed` and
320+
// assumed stable. Pin the analyzer to mark every such target as
321+
// mutated so the elisions correctly bail out. Caught by review on
322+
// PR #2 (`https://github.com/rebeckerspecialties/wasmtime/pull/2#
323+
// discussion_r3193374159` and `…#discussion_r3193374164`).
324+
// -----------------------------------------------------------------------------
325+
326+
/// A second elem segment whose offset is `(global.get $g)` (an imported
327+
/// global, resolved at instantiation time) cannot be folded into
328+
/// `precomputed` — `try_func_table_init` only folds segments with a
329+
/// constant `i32.const`/`i64.const` offset. So the segment stays in
330+
/// `table_initialization.segments` and overwrites slots at instance
331+
/// time. Without the leftover-segment pass in `analyze_table_mutability`,
332+
/// the table would be marked immutable and the type-confusion soundness
333+
/// bug fires (a function of a different signature could end up at slot
334+
/// 0 of an "immutable" table, defeating
335+
/// `try_elide_sig_check_for_immutable_table`).
336+
#[test]
337+
fn dynamic_offset_leftover_segment_marks_table_mutated() {
338+
let bits = translate_and_get_mutability(
339+
r#"
340+
(module
341+
(import "" "g" (global $g i32))
342+
(table 4 4 funcref)
343+
(func $f (result i32) i32.const 42)
344+
(elem (i32.const 0) func $f)
345+
(elem (offset (global.get $g)) func $f))
346+
"#,
347+
);
348+
assert_eq!(
349+
bits,
350+
vec![true],
351+
"leftover segment with dynamic offset must mark its target table mutated"
352+
);
353+
}
354+
355+
/// A segment in `Expressions` form (`funcref (item ref.func ...)`)
356+
/// rather than `Functions` form is also rejected by
357+
/// `try_func_table_init` and stays in `segments`. Same soundness
358+
/// argument as the dynamic-offset case: the segment's evaluation
359+
/// happens at instantiation time and can produce arbitrary funcrefs
360+
/// (including null via `ref.null func`), which would invalidate any
361+
/// elision proof that read from `precomputed`.
362+
#[test]
363+
fn expressions_form_leftover_segment_marks_table_mutated() {
364+
let bits = translate_and_get_mutability(
365+
r#"
366+
(module
367+
(table 4 4 funcref)
368+
(func $f (result i32) i32.const 42)
369+
(elem (i32.const 0) funcref (item ref.func $f) (item ref.null func)))
370+
"#,
371+
);
372+
assert_eq!(
373+
bits,
374+
vec![true],
375+
"Expressions-form leftover segment must mark its target table mutated"
376+
);
377+
}
378+
379+
/// `try_func_table_init` short-circuits the *whole* segment-folding
380+
/// loop on the first segment it can't fold — including any later
381+
/// segments that target a different table. This preserves wasm's
382+
/// trap-ordering semantics (the failing segment might trap, in which
383+
/// case later segments shouldn't have been applied either). The
384+
/// upshot: a single dynamic-offset segment can leave many leftover
385+
/// segments behind. Verify the analyzer marks every targeted table,
386+
/// not just the one whose segment broke the loop.
387+
#[test]
388+
fn leftover_segments_after_short_circuit_mark_all_targets() {
389+
let bits = translate_and_get_mutability(
390+
r#"
391+
(module
392+
(import "" "g" (global $g i32))
393+
(table $t0 4 4 funcref)
394+
(table $t1 4 4 funcref)
395+
(func $f (result i32) i32.const 42)
396+
;; First segment for t0 has dynamic offset → breaks the
397+
;; folding loop → both this segment AND the later t1
398+
;; segment stay in `table_initialization.segments`.
399+
(elem (table $t0) (offset (global.get $g)) func $f)
400+
(elem (table $t1) (i32.const 0) func $f))
401+
"#,
402+
);
403+
assert_eq!(
404+
bits,
405+
vec![true, true],
406+
"both targets of leftover segments must be marked mutated"
407+
);
408+
}
409+
410+
/// Independence sanity check: a leftover segment for table 0 must NOT
411+
/// mark a separate table 1 that has only foldable segments. Mirrors
412+
/// the `mutation_isolated_to_target_table` test for runtime opcodes.
413+
#[test]
414+
fn leftover_segment_marks_only_its_target_table() {
415+
let bits = translate_and_get_mutability(
416+
r#"
417+
(module
418+
(import "" "g" (global $g i32))
419+
(table $t0 4 4 funcref)
420+
(table $t1 4 4 funcref)
421+
(func $f (result i32) i32.const 42)
422+
;; Foldable segment for t1 — applied first, before any
423+
;; segment for t0 is reached. (Wasm specifies segments are
424+
;; processed in order, but `try_func_table_init` walks them
425+
;; in order too, so applying t1 first matches.)
426+
(elem (table $t1) (i32.const 0) func $f $f $f $f)
427+
;; Dynamic-offset (leftover) segment for t0.
428+
(elem (table $t0) (offset (global.get $g)) func $f))
429+
"#,
430+
);
431+
assert_eq!(
432+
bits,
433+
vec![true, false],
434+
"leftover-segment marking must not bleed into other tables"
435+
);
436+
}

0 commit comments

Comments
 (0)