Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 51 additions & 1 deletion contracts/plan-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,57 @@ Additions/corrections from the M0 integration, normative as of v0.1:
reuses resource-table instance mapping, which fails loudly (never
silently mis-routes) but is structurally the wrong index space. A v3
should add `errorContextTables` and the `TypeTupleIndex` mapping
together.
together. **[v3 correction, deltic#89: the "fails loudly" claim was
false — the resource-table accessor succeeds whenever a concrete
resource table exists at the colliding index, and a composed component
with an ErrorContext at the colliding slot mis-routes silently. Both
gaps closed in v3 below.]**

## v3 amendments (2026-08-10, deltic#89 / #99 / #101)

1. **`formatVersion` is now `3`** (strict equality both sides, same-commit
bump rule as v1/v2).
2. **`errorContextTables` section added**: entries
`{"instance": RuntimeComponentInstanceIndex}`, index space = wasmtime
`TypeComponentLocalErrorContextTableIndex`, emitted from environ's
`ComponentTypes.error_context_tables` (`TypeErrorContextTable.instance`,
types.rs:1147). The `error-context-transfer` trampoline's `srcTable`/
`dstTable` resolve through this section via a dedicated
`errorContextTableInstance(i)` accessor — never through
`resourceTables` (closes v2 amendment 3(b); loud `PlanError` on
out-of-range, no `?? 0` defaults). Digest-neutral, same rationale as the
v2 table sections.
3. **`task-return` trampolines carry `resultType`**: the wasmtime
`TypeTupleIndex` is interned into `plan.types` like every other type and
the trampoline decl gains `"resultType": <type index> | null` (null
accepted on the wire; the current producer always emits a tuple — a
no-result task carries the empty tuple). **Field-shape note (implementation
reality, deltic#89):** the v2 decl's `results` field already held the
*interned* index, which made it useless as the FACT lookup key; at v3
`results` carries the **raw** wasmtime `TypeTupleIndex` (the key
`prepare-call` passes at runtime) and `resultType` the interned
`plan.types` index. The loader builds the raw→interned dictionary and
rejects contradictory mappings. Executor obligation: enable the
previously-skipped `canon_task_return` **result-type check** for FACT
tasks (structural comparison against the task's declared result type,
definitions.py:2395-2396). The **memory-identity half of options
equality remains a named open gap** — it is not unblocked by the type
mapping (the relaxation is about `prepare-call.memory` being the
adapter's second-hand view against wasmtime's one-sided check),
re-justified at the site (intrinsics/fact_calls.ts / async_builtins.ts
CONTRACT notes).
4. **v1 amendment 4 (instance-tree gap) closed runtime-side — no wire
form** (deltic#101 adjudication). The reference's reachable
`entering_set` checks collapse under two facts: a host entry's entering
set always includes the top-level root, and guest-to-guest pairs never
consult intermediate ancestors reachably (FACT statically traps
same/ancestor pairs; sibling cycles are DAG-unreachable, deltic#99). A
**synthetic per-component-instantiation root** participating in
`mayEnterFrom`/`enterFrom`/`leaveTo` as every instance's shared parent
is therefore observably equivalent to the full chain, and matches
wasmtime's top-level-id shortcut (concurrent.rs:1876-1886) by
construction. Reopens only if a future upstream shape makes nesting
depth observable.

## Documentation amendments (C2)

Expand Down
2 changes: 2 additions & 0 deletions crates/translator-shim/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ fn map_translation(
let num_resource_tables = types.num_resource_tables();
let num_stream_tables = types.num_stream_tables();
let num_future_tables = types.num_future_tables();
let num_error_context_tables = types.num_error_context_tables();
let (component_types, _world_ty) = types.finish(&translation.component);

// Distinguish embedded modules (slices of the input) from FACT adapters
Expand Down Expand Up @@ -263,6 +264,7 @@ fn map_translation(
num_resource_tables,
num_stream_tables,
num_future_tables,
num_error_context_tables,
)
.build(producer, component_id, module_entries, &adapter_import_names)?;

Expand Down
75 changes: 70 additions & 5 deletions crates/translator-shim/src/plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ use wasmtime_environ::component::{
CanonicalOptions, CanonicalOptionsDataModel, Component, ComponentTypes, CoreDef, CoreExport,
Export, ExportItem, GlobalInitializer, InstantiateModule, InterfaceType, StringEncoding,
Trampoline, TrampolineIndex, TypeDef, TypeFuncIndex, TypeResourceTable,
TypeResourceTableIndex, TypeStreamTableIndex, TypeFutureTableIndex, TypeTupleIndex,
TypeComponentLocalErrorContextTableIndex, TypeResourceTableIndex, TypeStreamTableIndex,
TypeFutureTableIndex, TypeTupleIndex,
};
use wasmtime_environ::{EntityIndex, ModuleInternedTypeIndex, PrimaryMap, WasmValType};

Expand All @@ -49,12 +50,22 @@ use wasmtime_environ::{EntityIndex, ModuleInternedTypeIndex, PrimaryMap, WasmVal
/// `stream.read` targets table 3 but not what a table-3 element is, so it
/// cannot size or lift the copy buffer at all. The same gap in the other
/// direction (`task_return_type`, a `TypeTupleIndex` with no mapping into
/// `plan.types`) is still open — see intrinsics/fact_calls.ts.
/// `plan.types`) was still open at v2 — see below.
///
/// v3 (2026-08-10, contracts/plan-format.md "v3 amendments", deltic#89):
/// closes both v2 gaps.
/// * `errorContextTables` — the `error-context-transfer` trampoline's table
/// arguments live in the `TypeComponentLocalErrorContextTableIndex` space
/// and had no section, so the runtime resolved them through the
/// *resource*-table mapping (a different index space: silent mis-route in
/// a composition with an ErrorContext at a colliding slot).
/// * `task-return`'s `resultType` / raw `results` — see
/// `TrampolineDecl::TaskReturn`.
///
/// Per the contract's compat rule ("changes require updating both producer
/// and consumer in the same commit and bumping `formatVersion`") the bump is
/// unconditional even though the change is additive.
pub const FORMAT_VERSION: u32 = 2;
pub const FORMAT_VERSION: u32 = 3;

// ---------------------------------------------------------------------------
// Plan schema (serde structs; field order == emission order == contract order)
Expand Down Expand Up @@ -85,6 +96,15 @@ pub struct Plan {
/// `TypeFutureTableIndex`; referenced by the `futureTable` field of every
/// `future.*` trampoline. Plan v2.
pub future_tables: Vec<AsyncTableDecl>,
/// Error-context-table metadata, index space == wasmtime's
/// `TypeComponentLocalErrorContextTableIndex`; the space the
/// `error-context-transfer` trampoline's `srcTable`/`dstTable` runtime
/// arguments live in (fact/trampoline.rs:3526-3539). Emitted from
/// `ComponentTypes::error_context_tables` (`TypeErrorContextTable`,
/// types.rs:1144-1151), which carries nothing but the owning instance —
/// hence no `element` here. Plan v3 (contracts/plan-format.md v3
/// amendment 2).
pub error_context_tables: Vec<ErrorContextTableDecl>,
/// Resource types this component *imports*, in `ResourceIndex` order
/// (entry `i` is `ResourceIndex(i)`). Defined resources follow:
/// `ResourceIndex = importedResources.len() + DefinedResourceIndex`,
Expand Down Expand Up @@ -197,6 +217,15 @@ pub struct AsyncTableDecl {
pub instance: u32,
}

/// One entry of the `errorContextTables` section (plan v3). An error-context
/// table has no element type — wasmtime's `TypeErrorContextTable`
/// (types.rs:1144-1151) is exactly `{ instance }`.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ErrorContextTableDecl {
pub instance: u32,
}

/// `CoreDef`, tag-for-tag per plan-format.md.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
Expand Down Expand Up @@ -262,7 +291,24 @@ pub enum TrampolineDecl {
ResourceDrop { index: u32, instance: u32, resource: u32 },
BackpressureInc { index: u32, instance: u32 },
BackpressureDec { index: u32, instance: u32 },
TaskReturn { index: u32, instance: u32, results: u32, options: u32 },
/// `task.return`. Plan v3 splits what v2 conflated into one `results`
/// field:
///
/// * `results` is now the **raw** wasmtime `TypeTupleIndex` (u32), i.e.
/// verbatim the value FACT's `prepare-call` passes as its
/// `task_return_type` argument at runtime (fact.rs:47,584). Without
/// it a consumer cannot relate the callee's declared result type to
/// anything in the plan, which is exactly why
/// `canon_task_return`'s result-type check was skipped for FACT
/// tasks.
/// * `result_type` is that tuple interned into `plan.types` (the field
/// contracts/plan-format.md v3 amendment 3 names). `Option` for wire
/// symmetry with the other nullable decl fields only: wasmtime's
/// `Trampoline::TaskReturn.results` is a plain `TypeTupleIndex`
/// (info.rs:789-796, no `Option`), so this producer always emits a
/// number — a no-result task carries the *empty tuple*, not `null`.
TaskReturn { index: u32, instance: u32, results: u32, result_type: Option<u32>, options: u32 },

TaskCancel { index: u32, instance: u32 },
WaitableSetNew { index: u32, instance: u32 },
WaitableSetWait { index: u32, instance: u32, options: u32 },
Expand Down Expand Up @@ -568,6 +614,8 @@ pub struct PlanBuilder<'a> {
/// Same for stream/future tables (plan v2).
num_stream_tables: usize,
num_future_tables: usize,
/// Same for error-context tables (plan v3).
num_error_context_tables: usize,
/// RuntimeInstanceIndex -> StaticModuleIndex, built while walking
/// initializers in order.
instance_to_module: Vec<u32>,
Expand All @@ -584,6 +632,7 @@ impl<'a> PlanBuilder<'a> {
num_resource_tables: usize,
num_stream_tables: usize,
num_future_tables: usize,
num_error_context_tables: usize,
) -> Self {
PlanBuilder {
component,
Expand All @@ -593,6 +642,7 @@ impl<'a> PlanBuilder<'a> {
num_resource_tables,
num_stream_tables,
num_future_tables,
num_error_context_tables,
instance_to_module: Vec::new(),
type_table: Vec::new(),
type_index: HashMap::new(),
Expand Down Expand Up @@ -706,6 +756,17 @@ impl<'a> PlanBuilder<'a> {
});
}

// 5c. Error-context tables (plan v3). `PrimaryMap` order, i.e. the
// `TypeComponentLocalErrorContextTableIndex` space; walked by index so
// nothing hash-ordered can reach the output.
let mut error_context_tables = Vec::new();
for i in 0..self.num_error_context_tables {
let idx = TypeComponentLocalErrorContextTableIndex::from_u32(i as u32);
error_context_tables.push(ErrorContextTableDecl {
instance: self.types[idx].instance.as_u32(),
});
}

// 6. Imports (RuntimeImportIndex order).
let mut imports = Vec::new();
for (_, (import_idx, path)) in component.imports.iter() {
Expand Down Expand Up @@ -757,6 +818,7 @@ impl<'a> PlanBuilder<'a> {
resource_tables,
stream_tables,
future_tables,
error_context_tables,
imported_resources,
imports,
exports,
Expand Down Expand Up @@ -953,7 +1015,10 @@ impl<'a> PlanBuilder<'a> {
} => TrampolineDecl::TaskReturn {
index,
instance: instance.as_u32(),
results: self.intern_results_tuple(*results)?,
// Raw `TypeTupleIndex` (the runtime `task_return_type` key)
// *and* its interned `plan.types` entry — see the decl docs.
results: results.as_u32(),
result_type: Some(self.intern_results_tuple(*results)?),
options: options.as_u32(),
},
T::TaskCancel { instance } => TrampolineDecl::TaskCancel {
Expand Down
Binary file added crates/translator-shim/testdata/error-context.wasm
Binary file not shown.
20 changes: 20 additions & 0 deletions crates/translator-shim/testdata/error-context.wat
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
;; (v3) error-context tables: a component whose canonical built-ins allocate a
;; `TypeComponentLocalErrorContextTableIndex` — the index space the plan's
;; `errorContextTables` section describes (contracts/plan-format.md v3
;; amendment 2). The memory lives in its own core module so
;; `error-context.new` can name it without a cycle.
(component
(core module $Mem (memory (export "mem") 1))
(core instance $mem (instantiate $Mem))
(core func $ec-new (canon error-context.new (memory $mem "mem")))
(core func $ec-drop (canon error-context.drop))
(core module $M
(import "" "ec-new" (func $ec-new (param i32 i32) (result i32)))
(import "" "ec-drop" (func $ec-drop (param i32)))
(func (export "f") (param i32) (result i32)
(call $ec-drop (call $ec-new (i32.const 0) (i32.const 0)))
(i32.const 0)))
(core instance $i (instantiate $M
(with "" (instance (export "ec-new" (func $ec-new)) (export "ec-drop" (func $ec-drop))))))
(func (export "f") (param "x" u32) (result u32)
(canon lift (core func $i "f"))))
2 changes: 1 addition & 1 deletion crates/translator-shim/testdata/gen.sh
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
# cargo run -p translator-shim --example emit-testdata -- <name>
set -e
cd "$(dirname "$0")"
for f in trivial linked async-lift async-linked imports imported-resource; do
for f in trivial linked async-lift async-linked imports imported-resource error-context; do
wasm-tools parse "$f.wat" -o "$f.wasm"
echo "generated $f.wasm"
done
98 changes: 95 additions & 3 deletions crates/translator-shim/tests/translate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,13 +140,20 @@ fn async_lift_translates() {
.trampolines
.iter()
.find_map(|tr| match tr {
TrampolineDecl::TaskReturn { results, options, .. } => Some((*results, *options)),
TrampolineDecl::TaskReturn {
result_type,
options,
..
} => Some((*result_type, *options)),
_ => None,
})
.expect("task-return trampoline required");
// The results reference points at a tuple entry in the types table.
// Plan v3: `resultType` is the interned entry (`results` next to it is the
// raw wasmtime `TypeTupleIndex`, a different space). It points at a tuple
// entry in the types table.
let result_type = task_return.0.expect("v3 emits an interned result tuple");
assert!(matches!(
t.plan.types[task_return.0 as usize],
t.plan.types[result_type as usize],
TypeDecl::Value(ValTypeJson::Tuple { .. })
));
assert!((task_return.1 as usize) < t.plan.canonical_options.len());
Expand Down Expand Up @@ -653,3 +660,88 @@ fn imported_resources_are_emitted() {
.any(|t| matches!(t, TrampolineDecl::ResourceDrop { .. }))
);
}

/// Plan v3 (contracts/plan-format.md v3 amendments 2 and 3).
///
/// (2) `errorContextTables` describes the index space the
/// `error-context-*` trampolines' table arguments live in
/// (`TypeComponentLocalErrorContextTableIndex`), emitted from environ's
/// `ComponentTypes::error_context_tables` in `PrimaryMap` order.
#[test]
fn error_context_tables_are_emitted() {
let bytes = build("error-context");
let t = translate(&bytes).unwrap();

assert_eq!(t.plan.format_version, 3);
// One table, owned by the single runtime component instance.
assert_eq!(t.plan.error_context_tables.len(), 1);
assert_eq!(t.plan.error_context_tables[0].instance, 0);
// ...and the trampolines index into it.
let table = t
.plan
.trampolines
.iter()
.find_map(|tr| match tr {
TrampolineDecl::ErrorContextNew {
error_context_table,
..
} => Some(*error_context_table),
_ => None,
})
.expect("error-context-new trampoline required");
assert!(
(table as usize) < t.plan.error_context_tables.len(),
"table {table} out of range of errorContextTables"
);
}

/// (3) A `task-return` decl carries BOTH the raw wasmtime `TypeTupleIndex`
/// (`results` — the value FACT's `prepare-call` passes as `task_return_type`
/// at runtime) and its interning into `plan.types` (`resultType`). The two are
/// different index spaces; conflating them is what left `canon_task_return`'s
/// result-type check disabled for FACT tasks through v2.
#[test]
fn task_return_carries_raw_and_interned_result_types() {
let bytes = build("async-linked");
let t = translate(&bytes).unwrap();

let decls: Vec<(u32, Option<u32>)> = t
.plan
.trampolines
.iter()
.filter_map(|tr| match tr {
TrampolineDecl::TaskReturn {
results,
result_type,
..
} => Some((*results, *result_type)),
_ => None,
})
.collect();
assert!(!decls.is_empty(), "async-linked must have task-return trampolines");
for (raw, interned) in &decls {
let interned = interned.expect("v3 always interns the result tuple");
assert!(
matches!(
t.plan.types[interned as usize],
TypeDecl::Value(ValTypeJson::Tuple { .. })
),
"resultType {interned} must name a tuple type"
);
// The raw index is wasmtime's own and is not a plan.types index; it is
// only required to be stable and to round-trip through the loader's
// dictionary, which the runtime suite pins (tests/plan_v3_test.ts).
let _ = raw;
}
// One raw tuple index maps to exactly one interned entry.
let mut sorted = decls.clone();
sorted.sort();
sorted.dedup();
for (raw, _) in &sorted {
assert_eq!(
sorted.iter().filter(|(r, _)| r == raw).count(),
1,
"raw tuple {raw} maps to more than one plan.types entry"
);
}
}
7 changes: 7 additions & 0 deletions runtime/src/exec/boundary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1133,10 +1133,17 @@ export function createLiftedFunction(input: {
*/
const poison = (e: unknown): void => {
entered = false; // consumed: the lock is now permanent
// ...for the leaf. The synthetic per-instantiation root (plan v3
// amendment 4) is in `enteredSet` too, and leaving IT locked would
// poison every instance of the component — exactly the store-wide
// behaviour the paragraph above says this runtime deliberately does not
// have. Released; see `releaseSyntheticRootOnPoison` in task/mod.ts.
inst.releaseSyntheticRootOnPoison();
// Through the seam (not retireInstanceAsyncEnds directly) so the
// poison marker is recorded too — `Thread.resumeWith` retires this
// instance's late settles against it instead of assert-cascading.
for (const i of enteredSet) {
if (i.isSyntheticRoot) continue;
notifyInstancePoisoned(
i as unknown as { handles: Iterable<unknown> },
e,
Expand Down
Loading
Loading