oficina / docs Public

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
Paul Campbell pushed by paul@keymail.dev 734b6f35ebf2a8e921cab0280d0cae7369a51c82 parent e04e106
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)