Plan: Task 6 caps execution and discards trapped instances
Three amendments from Task 5's execution. text_len now returns i32, so the Go host must check for a negative code before treating it as a length -- read as unsigned, ERR_NO_DOC becomes 0xFFFFFFFF and the host tries to allocate four gigabytes. alloc returns 0 on failure and the host must check it. And the host must cap execution rather than only surviving traps. A 36-byte corrupted fixture ran apply for 163 real seconds and returned the correct answer, so no correctness test can see it -- but it is denial of service on a server that accepts updates from anyone with a share link. WithCloseOnContextDone plus a per-call timeout, and a deadline is treated exactly like a trap. The trap-discard requirement now has a test attached. yrs calls from_utf8_unchecked on wire data, the per-document instance is what contains that, and the containment is only real if the host actually throws the instance away.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011F5CivVm4CmL9ByfSSAaXA
1 file changed,
+31
−1
docs/superpowers/plans/2026-08-30-rust-core-wire-compat.md+31 −1
diff --git a/docs/superpowers/plans/2026-08-30-rust-core-wire-compat.md b/docs/superpowers/plans/2026-08-30-rust-core-wire-compat.md| index ae24070..3afdbbb 100644 |
| --- a/docs/superpowers/plans/2026-08-30-rust-core-wire-compat.md |
| +++ b/docs/superpowers/plans/2026-08-30-rust-core-wire-compat.md |
| @@ -583,7 +583,7 @@ makes 'never edit a fixture to make a test pass' mechanical." |
| - `docs_core_doc_new(guid_ptr: u32, guid_len: u32, client_id: u64) -> u32` — returns a document handle, 0 on failure. **The GUID and client id come from the host, never from `yrs`** (Global Constraints) |
| - `docs_core_doc_free(handle: u32)` |
| - `docs_core_apply(handle: u32, ptr: u32, len: u32) -> i32` — 0 ok, negative on error |
| - - `docs_core_text_len(handle: u32) -> u32` |
| + - `docs_core_text_len(handle: u32) -> i32` — byte length, or a negative error code. **Amended during execution:** it was `u32`, which made an unknown handle indistinguishable from an empty document |
| - `docs_core_text_read(handle: u32, out_ptr: u32, out_len: u32) -> i32` — bytes written, negative on error |
| - [ ] **Step 1: Write the failing test** |
| @@ -806,6 +806,26 @@ where an error code costs nothing." |
| This is the task that answers the plan's question. Everything before it proves Rust and JS agree; only this proves the artifact runs where the server needs it. |
| +**Two requirements added during execution, both from Task 5's findings:** |
| + |
| +**The runtime must cap execution, not merely survive traps.** A 36-byte |
| +corrupted fixture ran `apply` for **163 real seconds** before returning the |
| +correct answer. That is not a hang and no correctness test can see it, but on |
| +a server accepting updates from anyone holding a share link it is denial of |
| +service. Build the runtime with |
| +`wazero.NewRuntimeConfig().WithCloseOnContextDone(true)` and give every |
| +guest call a `context.WithTimeout`. Treat a deadline exceedance exactly like |
| +a trap: discard the instance, rebuild from snapshot. The original design |
| +covered an instance that dies and said nothing about one that never returns. |
| + |
| +**A trap must discard the module instance.** `yrs` calls |
| +`from_utf8_unchecked` on wire data, so a malformed update can trap and its |
| +linear memory is then unusable. The per-document instance is what contains |
| +that, and the containment is only real if the host actually throws the |
| +instance away — a host that reuses a trapped instance makes it theoretical. |
| +There must be a test: trap an instance, then confirm a fresh one for the same |
| +document rebuilds correctly from its updates. |
| + |
| - [ ] **Step 1: Initialise the Go module and vendor the wasm** |
| ```bash |
| @@ -1077,6 +1097,12 @@ func (h *Handle) Text(ctx context.Context) (string, error) { |
| if err != nil { |
| return "", fmt.Errorf("text_len: %w", err) |
| } |
| + // text_len returns i32: negative is an error code, not a length. Reading |
| + // it as unsigned would turn ERR_NO_DOC into 0xFFFFFFFF and then try to |
| + // allocate four gigabytes. |
| + if code := int32(lenRes[0]); code < 0 { |
| + return "", fmt.Errorf("text_len failed: code %d", code) |
| + } |
| n := lenRes[0] |
| if n == 0 { |
| return "", nil |
| @@ -1087,6 +1113,10 @@ func (h *Handle) Text(ctx context.Context) (string, error) { |
| return "", fmt.Errorf("alloc: %w", err) |
| } |
| ptr := ptrRes[0] |
| + // alloc returns 0 on failure, including for any length over isize::MAX. |
| + if ptr == 0 { |
| + return "", fmt.Errorf("alloc(%d) failed", n) |
| + } |
| defer h.free.Call(ctx, ptr, n) |
| rc, err := h.textRead.Call(ctx, h.handle, ptr, n) |