Skip to content
Merged
21 changes: 18 additions & 3 deletions contracts/descriptor-ir.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ the implementation truth; the destination is now embedder-api.md's.
|---|---|
| bool | `boolean` |
| s8..u32, f32, f64, char (as code point) | `number` |
| s64/u64 | `bigint` (range-checked at lower) |
| s64/u64 | `bigint` (**v0.2 correction**: NOT range-checked at lower — the interpreter wraps mod 2⁶⁴ like every other integer lane, matching definitions.py's `% 2**64`; the original claim never matched the implementation) |
| string | `string`; lowering applies USVString replacement (`toWellFormed`) |
| list<u8> | `Uint8Array` (always a copy, never a view into guest memory) |
| other lists / tuples | `Array` |
Expand Down Expand Up @@ -117,8 +117,11 @@ extension must land with fixtures.
instantiate), so resource-type identity never leaks across instances.
- Variant/option host shapes are settled for the interpreter but bindgen (§9)
may want ergonomic variations — any change lands here first.
- `map` (in types.ts, from the reference) is not emitted by current
translators; keep behind a fixture-only flag.
- ~~`map` (in types.ts, from the reference) is not emitted by current
translators; keep behind a fixture-only flag.~~ **Closed (v0.2): stale.**
The shim enables `CM_MAP` and emits `map` types (translator-shim lib.rs
`features()`, plan.rs `ValTypeJson::Map`); the loader consumes them. `map`
despecializes to `list<record{0,1}>` per the reference.

## v0.1 amendments (post-M0 reality)

Expand All @@ -130,3 +133,15 @@ extension must land with fixtures.
2. **Flattening contract validated as written**: computed `flattenFunctype`
vs the options' `coreType` asserted at instantiate across the whole
fixture corpus with zero mismatches. No change.

## v0.2 amendments (2026-08-10 adversarial review, deltic#98)

Documentation corrections only — no wire or behavior change:

1. **s64/u64 "range-checked at lower" claim retracted** (host-mapping table
above): the interpreter has always wrapped bigint lanes mod 2⁶⁴ per
definitions.py `lower_flat`; the table asserted a check that never
existed. Host-side range *asserts* (host-precondition errors, not traps)
exist only on the scalar `storeInt` path as of deltic#96.
2. **`map` open item closed as stale** (struck through above): emitted by the
shim, consumed by the loader, exercised by the values suite.
25 changes: 24 additions & 1 deletion contracts/embedder-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ pins u8 stream chunks as `Uint8Array` in both directions; amendment A6
examination", renumbered from a colliding second "A5"); amendment A7
(2026-08-11) makes component faults loud on host stream/future
operations (`PeerTrappedError`, never a hang or a fake end-of-stream)
and limits host ends to one in-flight operation per direction.** This document supersedes `descriptor-ir.md`'s interim
and limits host ends to one in-flight operation per direction; amendment
A8 (2026-08-10, deltic#90/#97) makes `Future.drop()` before writing an
**abandonment** (total, never-throwing; a guest reader observes a trap at
its rendezvous, never DROPPED) and documents host `cancelRead` as
indistinguishable from end-of-stream by design.** This document supersedes `descriptor-ir.md`'s interim
"host value mapping" table as the destination for host-facing value shapes.
The runtime's *raw* boundary (`instance.exports`, `HostImports`) keeps the
`definitions.py` interpreter shapes as an **internal** surface; the
Expand Down Expand Up @@ -412,6 +416,25 @@ class DroppedError extends Error { … } // awaiting a dropped future rejects
the same stream stays legal (they are different ends). Previously the
second operation could "rendezvous" against the first one's parked
buffer and report data as taken by a peer that never existed.
- **Dropping an unwritten future is abandonment, not DROPPED** (amendment
A8, deltic#90). The CABI forbids a writable future end from dropping
before delivering its value (definitions.py:1183-1184) — a guest doing
so traps. The host-side spelling: `Future.drop()`/`[Symbol.dispose]` on
a **lowered**, never-written future never throws and is idempotent; the
guest-held readable end observes a **trap at its rendezvous point**
("the host dropped the writable end without writing a value") — pending
read, later read, or waitable-set delivery alike — never a DROPPED
event (which the CABI says a future reader cannot see) and never a
hang. An unlowered future (the guest never saw it) just releases state.
Producer failures (`Promise` rejection under `lowerFutureSource`) keep
their A7-era reporting: the in-flight call fails site-named via the
host-failure channel.
- **`cancelRead` is indistinguishable from end-of-stream — by design**
(amendment A8, deltic#97). A host-side `Stream.cancelRead()` settles the
in-flight `read` with an empty chunk, which `readable()`/the async
iterator present as clean EOS. The canceller is the same code observing
the end, so no discriminated signal is warranted; pinned by test. (A
*peer* fault is never presented this way — that is A7's rule.)

## Module wiring and instantiation

Expand Down
8 changes: 7 additions & 1 deletion contracts/intrinsics.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,13 @@ core" is a feature, not a crash.
restore `may_leave` on all component instances — FACT clears it around
lift/lower and a trap skips its restore; without both unwinds the
instance is unusable for post-trap re-entry, which this runtime
deliberately supports.
deliberately supports. **Scope clarification (2026-08-10, deltic#91):
the obligation covers every window that registers lenders, including
the prepare/start protocol** — `sync-start-call`'s inline lender scope
and `async-start-call`'s subtask-attached lenders release on every
non-success exit that does not poison the caller (trap rethrow AND
capability signals: `NeedsJspi` is expressly non-poisoning and must not
strand lenders).
3. **Host-trap preservation across nested barriers**: the trap trampoline
must (re)record the pending trap before every throw, so the specific
message survives arbitrarily nested adapter exception barriers. Residual,
Expand Down
23 changes: 23 additions & 0 deletions crates/translator-shim/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,32 @@ fn features() -> wasmparser::WasmFeatures {
f.insert(wasmparser::WasmFeatures::CM_MAP);
f.insert(wasmparser::WasmFeatures::CM_IMPLEMENTS);
f.insert(wasmparser::WasmFeatures::CM_THREADING);
// ISSUE #95 TRIPWIRE — do not enable `CM_VALUES`.
//
// Trusted wasmtime-environ 47.0.3's component frontend has two
// `unimplemented!()` panics that a `CM_VALUES`-accepted component can
// reach: a component `start` section (translate.rs:1338) and a
// component-level value import/export (translate.rs:1499). With the
// feature off (the wasmparser 0.252 default excludes it, and nothing
// above turns it on), `wasmparser::Validator` rejects both shapes during
// validation — a `TranslateError { phase: Validation, .. }` envelope,
// never reaching the translator body that panics. That is exercised and
// pinned by `tests/cm_values_tripwire.rs`.
//
// Turning `CM_VALUES` on would convert that validation-phase rejection
// into a genuine panic. On the native (test) build that unwinds and is
// merely an ugly failure; on the wasm32-unknown-unknown C-ABI build this
// crate ships (`just shim`; `Cargo.toml`'s release profile pins
// `panic = "abort"` for that target — see the note on `catch_unwind`
// below) it is a hard trap with **no JSON envelope at all**, violating
// this crate's "never panics on invalid input" claim (see the doc
// comment on the C-ABI entry point). If `CM_VALUES` is ever enabled here,
// the translate.rs call sites above need a real plan-format mapping (or
// an explicit `phase: Unsupported` pre-check) before the flag flips.
f
}


/// Feature names recorded in `plan.producer.features`. Must describe
/// `features()` — part of the artifact-cache key.
fn feature_names() -> Vec<String> {
Expand Down
62 changes: 62 additions & 0 deletions crates/translator-shim/tests/cm_values_tripwire.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
//! ISSUE #95 tripwire: pins that a component using a `start` section, or a
//! component-level `value` import, is rejected in the VALIDATION phase
//! today — never reaching the `unimplemented!()` panics trusted
//! wasmtime-environ 47.0.3's `translate.rs` has for both shapes
//! (`:1338` for `start`, `:1499` for values). See the `CM_VALUES` comment
//! on `features()` in `src/lib.rs`.
//!
//! Both shapes are gated by wasmparser's `cm_values` feature
//! (`wasmparser::WasmFeatures::CM_VALUES`, see
//! `validator/component.rs::ComponentState::add_start`'s
//! `require_feature::cm_values` call), which `features()` never enables —
//! `wasmparser::Validator` rejects them before `Translator::translate` ever
//! sees them. If a future change to `features()` turns `CM_VALUES` on,
//! these tests start failing (the panic aborts the *test process*, which
//! `cargo test` reports as a hard crash rather than a clean assertion
//! failure) — that failure mode is itself the tripwire.

use translator_shim::{translate, Phase};

/// A component with a top-level `start` function. Not decodable to a
/// meaningful plan under `CM_VALUES` off; must be a validation-phase
/// rejection ("component model `value`s" feature-gate error), not a panic.
#[test]
fn start_section_is_a_validation_rejection() {
let wat = r#"
(component
(core module $m
(func (export "f"))
)
(core instance $i (instantiate $m))
(func $f (canon lift (core func $i "f")))
(start $f)
)
"#;
let bytes = wat::parse_str(wat).expect("start-section component should parse as WAT");
let err = translate(&bytes).expect_err("start section must be rejected, not accepted");
assert_eq!(
err.phase,
Phase::Validation,
"start section must be a VALIDATION verdict (assert_invalid-equivalent), \
not Unsupported/Internal — got {err:?}",
);
}

/// A component-level `value` import. Same feature gate, same expected
/// verdict.
#[test]
fn value_import_is_a_validation_rejection() {
let wat = r#"
(component
(import "v" (value string))
)
"#;
let bytes = wat::parse_str(wat).expect("value-import component should parse as WAT");
let err = translate(&bytes).expect_err("value import must be rejected, not accepted");
assert_eq!(
err.phase,
Phase::Validation,
"value import must be a VALIDATION verdict (assert_invalid-equivalent), \
not Unsupported/Internal — got {err:?}",
);
}
38 changes: 36 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,20 @@ pump stands down whenever an export-call driver is live (the invariant and
its benignity argument are documented at the site in
`runtime/src/exec/boundary.ts`).

Named divergence (2026-08-10, [#92](https://github.com/lann/deltic/issues/92)):
**the async form of `subtask.cancel` is not atomic under jspi.** The
reference built-in returns `[BLOCKED]` with no suspension; deltic parks the
caller on a determinacy wait so the BLOCKED/resolved answer matches the
reference's synchronous-delivery outcomes across the engine's mandatory
microtask hop (jspi pin (j), pinned by `cancellable.wast`). While parked,
other ready threads of the store may run, so sibling-task effects can become
observable across the single built-in call — a reordering *within* the
reference's own `Store.tick` freedom, taken one built-in early; every
interleaved sibling was already at a block point. Rationale and mechanics at
the site (`runtime/src/intrinsics/async_builtins.ts`, the determinacy park
in `createSubtaskCancel`); regression pinned across seeds by
`runtime/tests/cancel_bracket_race_test.ts`.

## 7. Canonical ABI decisions

Authority: [CanonicalABI.md] and its executable reference
Expand Down Expand Up @@ -379,13 +393,33 @@ decide deliberately and document here.
is a core function `[rep] -> []`, invoked as a normal **non-async**
cross-component call — *"the destructor may not block. However, the
destructor may spawn a cooperative thread that does."* Reentrance is checked
(`may_enter_from`) with the same-instance exemption. Host policy:
(`may_enter_from`) with the same-instance exemption, and a trapping dtor
poisons the **implementing** instance (the reference's `Store.lift` bracket,
reconstructed at `runtime/src/cabi/handles.ts` `callDtorGated` —
implemented at [#85](https://github.com/lann/deltic/issues/85); the
same-instance exemption falls out of `entering_set`, not a special case).
Host policy:
- CM-level blocking in a dtor → deterministic trap (falls out of general
sync-task rules).
- Host-import latency is invisible to CM semantics; a dtor calling a
`Suspending` host import is legal but needs a suspension-legal stack:
JS-initiated drops (`using`, FinalizationRegistry) enter via a `promising`
trampoline; guest-initiated drops stay on pure-wasm paths (§5).
trampoline (`ResourceTypeInfo.dtorHost`, wired by the executor in jspi
mode for suspension-capable dtors — a non-suspendable dtor keeps the
exact synchronous path, avoiding the promising microtask hop's
one-turn entered window; the async entry bracket is held until the
activation settles, tracked in `pendingHostCalls`). **Known
limitation** (#85 scope note): a
*guest*-initiated drop reaches the dtor through a JS trampoline frame,
not the §5 pure-wasm funcref path — a Suspending import under it is a
deterministic JSPI frame-rule trap, not a supported suspension. The
pure-wasm dispatch path is future machinery; until then §5's
"guest-initiated dtor calls route through generated wasm" is aspiration,
not description.
- Host-held own handles carry lend tracking mirroring `num_lends`
([#86](https://github.com/lann/deltic/issues/86)): drop/GC-backstop defer
while lent; a backstop dtor trap poisons the implementing instance and
lands on the host-failure channel (never `catch {}`-swallowed).
- Upstream spec findings related to drops and backpressure (vestigial
`$async?` on `resource.drop`; dead `canon_backpressure_set` in
definitions.py) are tracked in
Expand Down
4 changes: 3 additions & 1 deletion justfile
Original file line number Diff line number Diff line change
Expand Up @@ -169,8 +169,10 @@ browsers:

# Translate all eight targets, then execute the suites.
# polymorph-tls conformance under deltic (issue #18).
# (--allow-env: tools/smoke-c0/common.ts reads POLYMORPH_ROOT at module
# scope since the wosh rename; the leg tasks always had it via deno task.)
smoke-tls: shim
deno run --allow-read tools/smoke-tls/run.ts --exec
deno run --allow-read --allow-env tools/smoke-tls/run.ts --exec

# The C0 smoke legs (tools/smoke-c0/REPORT.md).
smoke-c0: shim
Expand Down
23 changes: 19 additions & 4 deletions runtime/src/cabi/bulk_lists.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,25 @@
// loop body with one typed-array view per list and a tight per-element pass
// that preserves the interpreted path's EXACT observable semantics:
//
// * integers: the same `assert_` texts as `storeInt` (`"int store"`,
// `"64-bit store requires bigint"`), the same wrap-on-overflow (a
// TypedArray element write coerces exactly like the matching DataView
// setter), the same number/bigint host shapes on lift;
// * integers: the same `assert_` type-shape texts as `storeInt` (`"int
// store"`, `"64-bit store requires bigint"`) but, unlike the scalar path
// in memory.ts (`storeInt`'s range `assert_`s, issue #96), NOT the same
// range check: this bulk path wraps out-of-range values instead of
// raising the host-precondition error (`OverflowError` per
// definitions.py:1568-1569 `int.to_bytes`) that `storeInt` raises. That
// is a deliberate scalar/bulk posture split, not an oversight:
// - the whole point of this file (see the perf numbers above) is an
// allocation-free, branch-minimal per-element loop; an added range
// check is itself a per-element cost, defeating the purpose;
// - values reaching this path from a descriptor-driven lower already
// went through the descriptor layer's own type conversions for the
// cases that matter in practice (see contracts/descriptor-ir.md);
// the wrap here is a defense-in-depth gap only for a raw/buggy
// embedder value, which the scalar path (used for non-bulk-eligible
// kinds, and reachable directly from embedder code) still catches.
// A TypedArray element write coerces exactly like the matching DataView
// setter (wraps mod 2^width), so this is pinned as intentional behavior
// (see bulk_list_test.ts), not merely undocumented;
// * floats: the deterministic profile's NaN canonicalization on BOTH
// directions (float.ts `decodeI32AsFloat` / `encodeFloatAsI32`): every
// lifted NaN becomes the JS canonical NaN, every stored `number` NaN
Expand Down
Loading
Loading