internal/core: run the Rust core under wazero
This is the task the plan existed to answer: a Rust y-crdt crate compiles to plain wasm32 with a C ABI and runs inside a Go binary with no cgo and no JS glue, reproducing fixtures generated by real yjs. One module instance per document, since instances are not safe for concurrent use. Garbage input returns an error and leaves the instance usable, which is what makes §5.2's trap-recovery path rare rather than routine. Two requirements surfaced by Task 5's findings, both proven with real reproducers rather than asserted: - Every guest call runs under a context.WithTimeout, and the Runtime is built with WithCloseOnContextDone(true) so wazero actually terminates an overrunning call instead of merely abandoning the caller's wait. TestSlowUpdateIsCutOffByGuestCallTimeout replays the exact fuzz sequence from core/tests/wasm_trap_containment.rs (trial 1133, seed 0xC0FFEE) to reach the same pathological 36-byte update that ran for 163s under wasmi -- confirmed here to still run for ~4.3s uncapped under wazero's compiler engine -- and shows a short caller deadline cuts it off at ~300ms instead. - A trap discards the module instance rather than reusing it. TestTrappedInstanceIsDiscardedAndRebuilds applies the confirmed MemoryOutOfBounds repro from wasm_trap_containment.rs's borrow_leak_repro test, then verifies a fresh instance rebuilds from the update log and pre-trap content survives, and that the trap in one document's instance never touches another's. Both fatal cases funnel through the same rebuild() path, which strips the triggering context's deadline (context.WithoutCancel) before rebuilding -- recovery must not depend on the same budget that just ran out. testdata/docs_core.wasm is a checked-in copy of core's release build: go:embed cannot reach outside the module directory, so scripts/build-wasm.sh plus a copy step is the whole pipeline; Task 7 is expected to gate CI on that copy being fresh. Environment note for whoever runs `go` commands here: this repo's root also holds vendor/ (Rust's, unrelated to Go -- see vendor/README.md). Go's vendor auto-detection still trips on its presence even without a vendor/modules.txt, failing with "inconsistent vendoring" on plain `go build`/`go test`/`go vet`. Every go invocation above (and Task 7's CI gate) needs -mod=readonly (or GOFLAGS=-mod=readonly) until that's resolved.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011F5CivVm4CmL9ByfSSAaXA
6 files changed,
+812
−0
go.mod+7 −0go.sum+4 −0internal/core/core.go+394 −0internal/core/core_internal_test.go+234 −0internal/core/core_test.go+173 −0internal/core/testdata/docs_core.wasmbinary
diff --git a/go.mod b/go.mod| new file mode 100644 |
| index 0000000..6398cb1 |
| --- /dev/null |
| +++ b/go.mod |
| @@ -0,0 +1,7 @@ |
| +module github.com/carlosframework/docs |
| + |
| +go 1.26.5 |
| + |
| +require github.com/tetratelabs/wazero v1.12.0 |
| + |
| +require golang.org/x/sys v0.44.0 // indirect |
diff --git a/go.sum b/go.sum| new file mode 100644 |
| index 0000000..a10a279 |
| --- /dev/null |
| +++ b/go.sum |
| @@ -0,0 +1,4 @@ |
| +github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU= |
| +github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0= |
| +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= |
| +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= |
diff --git a/internal/core/core.go b/internal/core/core.go| new file mode 100644 |
| index 0000000..430745d |
| --- /dev/null |
| +++ b/internal/core/core.go |
| @@ -0,0 +1,394 @@ |
| +// Package core hosts the Rust document core as a WebAssembly module. |
| +// |
| +// The module is plain wasm32 with a C ABI and no wasm-bindgen, because |
| +// wazero provides no JS glue. Everything crosses the boundary as integers |
| +// and offsets into the module's linear memory. |
| +// |
| +// # Two failure modes the host must contain, not merely survive |
| +// |
| +// yrs decodes wire bytes with std::str::from_utf8_unchecked in places |
| +// (core/src/abi.rs's module doc comment), so a sufficiently corrupted |
| +// update can reach real undefined behaviour and trap the module. A wasm |
| +// trap does not unwind — the module's linear memory is left exactly as it |
| +// was at the moment of the trap, which for a thread-local RefCell borrow |
| +// guard means "permanently borrowed". The instance is not recoverable; |
| +// every Handle here is backed by exactly one module instance per document |
| +// (spec §5.2), and a trap discards that instance rather than reusing it. |
| +// |
| +// Separately, a malformed update need not trap at all: Task 5's fuzzing |
| +// found a 36-byte corrupted update that ran docs_core_apply for 163 real |
| +// CPU seconds before returning an ordinary, correct answer — not a hang, |
| +// but on a server accepting updates from anyone with a share link it is a |
| +// denial-of-service vector no correctness test can see. Every guest call |
| +// here runs under a context.WithTimeout, and the Runtime is built with |
| +// WithCloseOnContextDone(true) so wazero actually terminates a call that |
| +// overruns its budget rather than merely making the caller stop waiting. |
| +// A deadline exceeded is treated exactly like a trap: the instance is |
| +// discarded and a fresh one is rebuilt by replaying the updates already |
| +// known to have applied cleanly. |
| +package core |
| + |
| +import ( |
| + "context" |
| + _ "embed" |
| + "errors" |
| + "fmt" |
| + "time" |
| + |
| + "github.com/tetratelabs/wazero" |
| + "github.com/tetratelabs/wazero/api" |
| +) |
| + |
| +//go:embed testdata/docs_core.wasm |
| +var moduleWASM []byte |
| + |
| +// abiVersion is the contract this host understands. A module reporting |
| +// anything else is refused at load, so a stale .wasm (see |
| +// scripts/build-wasm.sh and the copy step in Task 6's brief) fails loudly |
| +// rather than behaving strangely. |
| +const abiVersion = 1 |
| + |
| +// guestCallTimeout bounds every single call into the wasm module. Measured |
| +// fuel consumption for real fixtures tops out in the tens of thousands of |
| +// wasmi fuel units (core/tests/wasm_trap_containment.rs); the 163-second |
| +// pathological input this guards against is not a close call. Five seconds |
| +// is generous for any call this host makes — alloc/free/doc_new/doc_free |
| +// are effectively instantaneous, and apply/text_read on real documents are |
| +// not on a per-keystroke hot path (spec §5.1) — while still being small |
| +// enough that a runaway call fails fast instead of tying up a goroutine for |
| +// minutes. |
| +const guestCallTimeout = 5 * time.Second |
| + |
| +// Runtime owns the compiled module. Compiling is expensive and |
| +// instantiating is not, so one Runtime is shared and each document gets its |
| +// own instance (spec §5.2: instances are not safe for concurrent use). |
| +type Runtime struct { |
| + rt wazero.Runtime |
| + compiled wazero.CompiledModule |
| +} |
| + |
| +func New(ctx context.Context) (*Runtime, error) { |
| + // WithCloseOnContextDone(true): without this, a context deadline or |
| + // cancellation on a guest call only makes the *caller* stop waiting — |
| + // the underlying goroutine executing wasm keeps running to completion. |
| + // With it, wazero itself terminates the call and closes the module |
| + // when the context is done, which is what turns "the 163-second input" |
| + // into a bounded failure instead of a resource leak. |
| + cfg := wazero.NewRuntimeConfig().WithCloseOnContextDone(true) |
| + rt := wazero.NewRuntimeWithConfig(ctx, cfg) |
| + compiled, err := rt.CompileModule(ctx, moduleWASM) |
| + if err != nil { |
| + rt.Close(ctx) |
| + return nil, fmt.Errorf("compiling core module: %w", err) |
| + } |
| + return &Runtime{rt: rt, compiled: compiled}, nil |
| +} |
| + |
| +func (r *Runtime) Close(ctx context.Context) error { return r.rt.Close(ctx) } |
| + |
| +// instantiate creates a fresh module instance and checks its reported ABI |
| +// version. Used both for a brand-new document and to rebuild after a |
| +// discarded (trapped or timed-out) instance. |
| +func (r *Runtime) instantiate(ctx context.Context) (api.Module, error) { |
| + cctx, cancel := context.WithTimeout(ctx, guestCallTimeout) |
| + defer cancel() |
| + // Empty name: wazero treats an anonymous module (ModuleName == "") as |
| + // exempt from its store's name-uniqueness check, so instantiating many |
| + // of these concurrently — one per document, plus rebuilds — never |
| + // collides (internal/wasm/store_module_list.go's registerModule). |
| + mod, err := r.rt.InstantiateModule(cctx, r.compiled, wazero.NewModuleConfig().WithName("")) |
| + if err != nil { |
| + return nil, fmt.Errorf("instantiating core module: %w", err) |
| + } |
| + |
| + version, err := callGuest(ctx, mod.ExportedFunction("docs_core_abi_version")) |
| + if err != nil { |
| + mod.Close(ctx) |
| + return nil, fmt.Errorf("reading abi version: %w", err) |
| + } |
| + if uint32(version[0]) != abiVersion { |
| + mod.Close(ctx) |
| + return nil, fmt.Errorf("core module reports ABI %d, host understands %d", version[0], abiVersion) |
| + } |
| + return mod, nil |
| +} |
| + |
| +// callGuest calls fn under guestCallTimeout. A non-nil error here means the |
| +// call trapped, ran past its deadline (in which case wazero has already |
| +// closed the module — WithCloseOnContextDone(true)), or the module was |
| +// otherwise closed out from under it: in every case the instance must be |
| +// treated as poisoned, never merely retried. The error is wrapped in |
| +// trapFault so callers further up (Apply, Text) can tell it apart from an |
| +// ordinary clean rejection (a negative return code from the guest), which |
| +// does not poison anything — see isFatal. |
| +func callGuest(ctx context.Context, fn api.Function, params ...uint64) ([]uint64, error) { |
| + cctx, cancel := context.WithTimeout(ctx, guestCallTimeout) |
| + defer cancel() |
| + res, err := fn.Call(cctx, params...) |
| + if err != nil { |
| + return nil, &trapFault{err} |
| + } |
| + return res, nil |
| +} |
| + |
| +// trapFault marks an error as meaning the module instance itself is no |
| +// longer trustworthy: the wasm call that produced it did not return |
| +// normally at all (a trap, a deadline exceeded, or the module having |
| +// already been closed), as opposed to returning normally with a negative |
| +// application error code. Every error that originates from a wazero |
| +// api.Function.Call failing carries this; every error this package |
| +// constructs itself from a clean negative return code does not, so |
| +// isFatal (via errors.As, walking any fmt.Errorf("...: %w", err) wrapping) |
| +// tells the two apart. |
| +type trapFault struct{ err error } |
| + |
| +func (t *trapFault) Error() string { return t.err.Error() } |
| +func (t *trapFault) Unwrap() error { return t.err } |
| + |
| +// isFatal reports whether err means the module instance must be discarded |
| +// (a trap or a deadline exceeded reaching all the way from callGuest) as |
| +// opposed to an ordinary clean rejection, which leaves the instance fully |
| +// usable (TestGarbageUpdateReturnsErrorNotTrap). |
| +func isFatal(err error) bool { |
| + var t *trapFault |
| + return errors.As(err, &t) |
| +} |
| + |
| +// Handle is one document, backed by its own module instance. Not safe for |
| +// concurrent use; callers serialise access per document. |
| +// |
| +// applied records, in order, every update byte slice this handle has |
| +// itself applied successfully (rc == 0). It exists solely so a trapped or |
| +// timed-out instance can be discarded and a fresh one rebuilt by replaying |
| +// only the updates already known to be good — the update that triggered |
| +// the discard is never replayed, matching §5.2's "the request fails |
| +// cleanly; the document does not." |
| +type Handle struct { |
| + rt *Runtime |
| + guid string |
| + clientID uint64 |
| + |
| + mod api.Module |
| + handle uint64 |
| + |
| + applied [][]byte |
| +} |
| + |
| +// NewDoc creates a document. The GUID and client id are supplied by the |
| +// caller and never drawn from yrs: on wasm the CRDT library's entropy is a |
| +// constant seed, so a value it generates is identical in every process, and |
| +// a repeated client id or GUID corrupts the document (Global Constraints). |
| +func (r *Runtime) NewDoc(ctx context.Context, guid string, clientID uint64) (*Handle, error) { |
| + mod, err := r.instantiate(ctx) |
| + if err != nil { |
| + return nil, err |
| + } |
| + |
| + handle := &Handle{rt: r, guid: guid, clientID: clientID, mod: mod} |
| + docHandle, err := createDoc(ctx, mod, guid, clientID) |
| + if err != nil { |
| + mod.Close(ctx) |
| + return nil, err |
| + } |
| + handle.handle = docHandle |
| + return handle, nil |
| +} |
| + |
| +// createDoc allocates the guid in mod's linear memory, calls |
| +// docs_core_doc_new, and frees the buffer. Shared between NewDoc and a |
| +// post-trap rebuild. |
| +func createDoc(ctx context.Context, mod api.Module, guid string, clientID uint64) (uint64, error) { |
| + guidBytes := []byte(guid) |
| + ptr, err := allocIn(ctx, mod, uint64(len(guidBytes))) |
| + if err != nil { |
| + return 0, fmt.Errorf("alloc for guid: %w", err) |
| + } |
| + defer freeIn(ctx, mod, ptr, uint64(len(guidBytes))) |
| + |
| + if !mod.Memory().Write(uint32(ptr), guidBytes) { |
| + return 0, fmt.Errorf("writing guid at %d: out of range", ptr) |
| + } |
| + |
| + res, err := callGuest(ctx, mod.ExportedFunction("docs_core_doc_new"), ptr, uint64(len(guidBytes)), clientID) |
| + if err != nil { |
| + return 0, fmt.Errorf("doc_new: %w", err) |
| + } |
| + if res[0] == 0 { |
| + return 0, fmt.Errorf("doc_new returned a null handle") |
| + } |
| + return res[0], nil |
| +} |
| + |
| +// allocIn calls docs_core_alloc and checks the 0-on-failure sentinel |
| +// (including any length over isize::MAX — core/src/abi.rs's layout_for). |
| +func allocIn(ctx context.Context, mod api.Module, length uint64) (uint64, error) { |
| + res, err := callGuest(ctx, mod.ExportedFunction("docs_core_alloc"), length) |
| + if err != nil { |
| + return 0, fmt.Errorf("alloc trapped or timed out: %w", err) |
| + } |
| + if res[0] == 0 { |
| + return 0, fmt.Errorf("alloc(%d) failed", length) |
| + } |
| + return res[0], nil |
| +} |
| + |
| +// freeIn best-efforts a free. A failure here (trap/timeout) is folded into |
| +// "the instance is now poisoned", which the caller's own callGuest error |
| +// (the one that actually matters) will already have triggered, so this |
| +// deliberately does not itself return an error to plumb through defers. |
| +func freeIn(ctx context.Context, mod api.Module, ptr, length uint64) { |
| + _, _ = callGuest(ctx, mod.ExportedFunction("docs_core_free"), ptr, length) |
| +} |
| + |
| +func (h *Handle) Close(ctx context.Context) error { |
| + _, err := callGuest(ctx, h.mod.ExportedFunction("docs_core_doc_free"), h.handle) |
| + if closeErr := h.mod.Close(ctx); err == nil { |
| + err = closeErr |
| + } |
| + return err |
| +} |
| + |
| +// rebuild discards h's current (poisoned) instance and replaces it with a |
| +// fresh one for the same document (guid, clientID), replaying every update |
| +// previously applied successfully. The update that triggered the discard |
| +// is not replayed — it is not in h.applied, since that log is only |
| +// appended to on a clean rc == 0. |
| +// |
| +// It deliberately does not run under ctx as given: ctx is the same context |
| +// whose deadline may be exactly what triggered this rebuild (a timed-out |
| +// Apply calls rebuild with its own ctx). Rebuilding under an |
| +// already-expired deadline would make recovery fail precisely in the case |
| +// it exists for, so this strips ctx's cancellation/deadline |
| +// (context.WithoutCancel) while keeping any values on it, and relies on |
| +// guestCallTimeout to still bound each individual call made during the |
| +// rebuild itself. |
| +func (h *Handle) rebuild(ctx context.Context) error { |
| + rebuildCtx := context.WithoutCancel(ctx) |
| + |
| + // Best-effort: the old instance may already be closed (a timeout under |
| + // WithCloseOnContextDone(true) closes it as part of terminating the |
| + // call), so an error here is expected and not itself a failure. |
| + _ = h.mod.Close(rebuildCtx) |
| + |
| + mod, err := h.rt.instantiate(rebuildCtx) |
| + if err != nil { |
| + return fmt.Errorf("rebuilding instance: %w", err) |
| + } |
| + docHandle, err := createDoc(rebuildCtx, mod, h.guid, h.clientID) |
| + if err != nil { |
| + mod.Close(rebuildCtx) |
| + return fmt.Errorf("rebuilding instance: %w", err) |
| + } |
| + for i, update := range h.applied { |
| + if err := applyOn(rebuildCtx, mod, docHandle, update); err != nil { |
| + mod.Close(rebuildCtx) |
| + return fmt.Errorf("rebuilding instance: replaying update %d/%d, previously applied cleanly: %w", |
| + i+1, len(h.applied), err) |
| + } |
| + } |
| + h.mod = mod |
| + h.handle = docHandle |
| + return nil |
| +} |
| + |
| +// applyOn runs docs_core_apply against a specific (mod, docHandle) pair — |
| +// factored out so both Handle.Apply and rebuild's replay share the same |
| +// alloc/write/call/free/error-code logic. |
| +func applyOn(ctx context.Context, mod api.Module, docHandle uint64, update []byte) error { |
| + ptr, err := allocIn(ctx, mod, uint64(len(update))) |
| + if err != nil { |
| + return fmt.Errorf("alloc: %w", err) |
| + } |
| + defer freeIn(ctx, mod, ptr, uint64(len(update))) |
| + |
| + if !mod.Memory().Write(uint32(ptr), update) { |
| + return fmt.Errorf("writing %d bytes at %d: out of range", len(update), ptr) |
| + } |
| + |
| + rc, err := callGuest(ctx, mod.ExportedFunction("docs_core_apply"), docHandle, ptr, uint64(len(update))) |
| + if err != nil { |
| + return fmt.Errorf("apply trapped or timed out: %w", err) |
| + } |
| + if code := int32(rc[0]); code != 0 { |
| + return fmt.Errorf("apply rejected the update: code %d", code) |
| + } |
| + return nil |
| +} |
| + |
| +// Apply applies update to the document. A clean rejection (a negative |
| +// error code from the guest, e.g. malformed wire bytes that yrs's decoder |
| +// catches) leaves the instance untouched and fully usable — that is the |
| +// whole point of §5.2's per-document instance: most bad input never needs |
| +// the instance discarded at all. A trap or a deadline exceeded is |
| +// different: the instance is discarded and rebuilt from every update |
| +// applied here so far, and the triggering update is reported as an error |
| +// but never retried or logged, so it can never itself be replayed into the |
| +// rebuilt instance. |
| +func (h *Handle) Apply(ctx context.Context, update []byte) error { |
| + err := applyOn(ctx, h.mod, h.handle, update) |
| + if err == nil { |
| + // Copy: the caller may reuse or mutate its update slice after this |
| + // call returns, and this log must survive a later rebuild. |
| + stored := make([]byte, len(update)) |
| + copy(stored, update) |
| + h.applied = append(h.applied, stored) |
| + return nil |
| + } |
| + if isFatal(err) { |
| + if rebuildErr := h.rebuild(ctx); rebuildErr != nil { |
| + return errors.Join(err, rebuildErr) |
| + } |
| + } |
| + return err |
| +} |
| + |
| +// Text returns the document's current text content. |
| +func (h *Handle) Text(ctx context.Context) (string, error) { |
| + text, err := textOn(ctx, h.mod, h.handle) |
| + if err == nil { |
| + return text, nil |
| + } |
| + if isFatal(err) { |
| + if rebuildErr := h.rebuild(ctx); rebuildErr != nil { |
| + return "", errors.Join(err, rebuildErr) |
| + } |
| + } |
| + return "", err |
| +} |
| + |
| +func textOn(ctx context.Context, mod api.Module, docHandle uint64) (string, error) { |
| + lenRes, err := callGuest(ctx, mod.ExportedFunction("docs_core_text_len"), docHandle) |
| + if err != nil { |
| + return "", fmt.Errorf("text_len trapped or timed out: %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 |
| + } |
| + |
| + ptr, err := allocIn(ctx, mod, n) |
| + if err != nil { |
| + return "", fmt.Errorf("alloc: %w", err) |
| + } |
| + defer freeIn(ctx, mod, ptr, n) |
| + |
| + rc, err := callGuest(ctx, mod.ExportedFunction("docs_core_text_read"), docHandle, ptr, n) |
| + if err != nil { |
| + return "", fmt.Errorf("text_read trapped or timed out: %w", err) |
| + } |
| + if written := int32(rc[0]); written < 0 { |
| + return "", fmt.Errorf("text_read failed: code %d", written) |
| + } |
| + |
| + buf, ok := mod.Memory().Read(uint32(ptr), uint32(n)) |
| + if !ok { |
| + return "", fmt.Errorf("reading %d bytes at %d: out of range", n, ptr) |
| + } |
| + return string(buf), nil |
| +} |
diff --git a/internal/core/core_internal_test.go b/internal/core/core_internal_test.go| new file mode 100644 |
| index 0000000..0a92d10 |
| --- /dev/null |
| +++ b/internal/core/core_internal_test.go |
| @@ -0,0 +1,234 @@ |
| +package core |
| + |
| +// White-box (package core) test for the execution cap. It needs the |
| +// unexported Runtime.instantiate/createDoc/applyOn helpers directly, and |
| +// constructs a Handle by hand, because reproducing a genuinely slow guest |
| +// call requires state this package's public API deliberately never |
| +// exposes: a *shared* module instance carrying the accumulated linear |
| +// memory of many prior documents. |
| +// |
| +// # Why this is the right way to prove the cap fires |
| +// |
| +// Task 5's fuzzing (core/tests/wasm_trap_containment.rs) found that |
| +// mutating the `concurrent` fixture with a fixed xorshift64 RNG (seed |
| +// 0xC0FFEE) and applying the mutation at trial index 1133 of a 1500-trial |
| +// run produced a 36-byte update that ran docs_core_apply for 163 real CPU |
| +// seconds under wasmi before returning an ordinary Ok — not a trap, a |
| +// pathologically slow decode. That 163-second number is specific to |
| +// wasmi's interpreter and to the *cumulative* linear-memory growth of the |
| +// 1133 documents created before it in that run (confirmed empirically |
| +// while building this test: the identical 36 bytes applied to a *fresh* |
| +// instance under wazero's compiler engine traps in ~3ms instead of running |
| +// long, and merely inflating memory with large alloc/free calls to a |
| +// comparable size does not reproduce the slowdown either — it depends on |
| +// the specific accumulated document state, not just the byte count of |
| +// memory grown). |
| +// |
| +// So this test replays the exact same deterministic sequence — same RNG, |
| +// same mutate(), same fixture cycling, same trial count — against a real |
| +// module instance to reach the same state, confirms trial 1133 reproduces |
| +// the exact bytes documented in wasm_trap_containment.rs (a drift check, |
| +// so a future change to that fuzz sequence fails loudly here instead of |
| +// silently testing nothing), and confirms that under wazero's compiler |
| +// engine this input is still genuinely slow (single-digit seconds, not |
| +// milliseconds) when left uncapped — then proves a short caller-imposed |
| +// deadline cuts it off well before that. |
| +// |
| +// It runs through the exported Handle.Apply, not a bespoke code path: the |
| +// only irregular part is how the Handle's *first* instance came to exist |
| +// (built by hand, already warmed up), everything after that first call is |
| +// exactly what a real caller sees. |
| + |
| +import ( |
| + "context" |
| + "encoding/hex" |
| + "fmt" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// rng and mutate below are a byte-for-byte port of |
| +// core/tests/wasm_trap_containment.rs's Rng/mutate — same xorshift64 |
| +// stream, same 1-3 byte-flip mutation — so that seeding with the same |
| +// 0xC0FFEE seed reproduces the exact same trial sequence. |
| +type rng struct{ x uint64 } |
| + |
| +func (r *rng) nextU64() uint64 { |
| + x := r.x |
| + x ^= x << 13 |
| + x ^= x >> 7 |
| + x ^= x << 17 |
| + r.x = x |
| + return x |
| +} |
| + |
| +func (r *rng) nextUsize(bound int) int { |
| + return int(r.nextU64() % uint64(bound)) |
| +} |
| + |
| +func mutate(r *rng, base []byte) []byte { |
| + data := make([]byte, len(base)) |
| + copy(data, base) |
| + nFlips := 1 + r.nextUsize(3) |
| + for i := 0; i < nFlips; i++ { |
| + if len(data) == 0 { |
| + break |
| + } |
| + idx := r.nextUsize(len(data)) |
| + data[idx] ^= byte(1 + r.nextUsize(255)) |
| + } |
| + return data |
| +} |
| + |
| +func loadFixtureBytes(t *testing.T, name string) []byte { |
| + t.Helper() |
| + b, err := os.ReadFile(filepath.Join("..", "..", "fixtures", "data", name+".update.bin")) |
| + if err != nil { |
| + t.Fatalf("reading fixture %s: %v", name, err) |
| + } |
| + return b |
| +} |
| + |
| +func TestSlowUpdateIsCutOffByGuestCallTimeout(t *testing.T) { |
| + ctx := context.Background() |
| + rt, err := New(ctx) |
| + if err != nil { |
| + t.Fatalf("core.New: %v", err) |
| + } |
| + t.Cleanup(func() { rt.Close(ctx) }) |
| + |
| + mod, err := rt.instantiate(ctx) |
| + if err != nil { |
| + t.Fatalf("instantiate: %v", err) |
| + } |
| + |
| + fixtureNames := []string{"hello", "unicode", "formatted", "concurrent"} |
| + fixtures := make([][]byte, len(fixtureNames)) |
| + for i, name := range fixtureNames { |
| + fixtures[i] = loadFixtureBytes(t, name) |
| + } |
| + |
| + // Warm up: replay trials 0..1132 exactly as |
| + // wasm_trap_containment.rs's fuzz loop does (fixture pick, mutate, |
| + // doc_new, apply), ignoring the outcome of each — most are clean |
| + // oks or clean rejections, and this loop must not go through |
| + // Handle.Apply (which would auto-rebuild on any genuine trap here |
| + // and undo the accumulated memory growth this test depends on). |
| + r := &rng{x: 0xC0FFEE} |
| + for i := 0; i < 1133; i++ { |
| + fi := r.nextUsize(len(fixtures)) |
| + mutated := mutate(r, fixtures[fi]) |
| + docHandle, err := createDoc(ctx, mod, fmt.Sprintf("warmup-%d", i), uint64(2_000_000+i)) |
| + if err != nil { |
| + t.Fatalf("warm-up trial %d: doc_new failed unexpectedly: %v", i, err) |
| + } |
| + _ = applyOn(ctx, mod, docHandle, mutated) // outcome irrelevant; only the memory growth matters |
| + } |
| + |
| + // Trial 1133 itself: the exact reproducer documented in |
| + // core/tests/wasm_trap_containment.rs ("a 3-byte-flipped `concurrent` |
| + // fixture ... ran for 163 seconds of real CPU time"). Confirm the RNG |
| + // sequence still lands on the documented bytes before relying on them. |
| + fi := r.nextUsize(len(fixtures)) |
| + mutated := mutate(r, fixtures[fi]) |
| + const wantHex = "02010200040107636f6e6f656e7403424293010100040107636f6e74656e74034141418a" |
| + if got := hex.EncodeToString(mutated); got != wantHex { |
| + t.Fatalf("RNG/mutate drifted from core/tests/wasm_trap_containment.rs: trial 1133 produced "+ |
| + "fixture=%s bytes=%s, want the documented bytes=%s — this test no longer exercises the "+ |
| + "known-slow reproducer", fixtureNames[fi], got, wantHex) |
| + } |
| + if fixtureNames[fi] != "concurrent" { |
| + t.Fatalf("expected trial 1133 to mutate the concurrent fixture, got %s", fixtureNames[fi]) |
| + } |
| + |
| + docHandle, err := createDoc(ctx, mod, "trial-1133", 2_000_000+1133) |
| + if err != nil { |
| + t.Fatalf("creating the document under test: %v", err) |
| + } |
| + |
| + // Sanity: confirm this input is genuinely slow (not just a fast trap) |
| + // under wazero's compiler engine at this memory-growth state, with a |
| + // budget generous enough to let it run to its natural conclusion. |
| + // Measured while building this test: ~4.36s (vs ~3ms with no memory |
| + // growth, and 163s under wasmi's interpreter on the equivalent state) |
| + // — well past what a per-keystroke host call should ever be allowed |
| + // to occupy a goroutine for. |
| + sanityCtx, sanityCancel := context.WithTimeout(ctx, 20*time.Second) |
| + sanityStart := time.Now() |
| + sanityErr := applyOn(sanityCtx, mod, docHandle, mutated) |
| + sanityElapsed := time.Since(sanityStart) |
| + sanityCancel() |
| + if sanityErr == nil { |
| + t.Fatalf("expected the known pathological update to fail (trap or time out) even given 20s, got a clean success") |
| + } |
| + if sanityElapsed < 200*time.Millisecond { |
| + t.Fatalf("expected the known pathological update to run for at least hundreds of "+ |
| + "milliseconds at this memory-growth state (it measured ~4.36s while building this test); "+ |
| + "it returned in %v instead — the reproducer may have stopped reproducing, which would make "+ |
| + "the capped assertion below meaningless", sanityElapsed) |
| + } |
| + t.Logf("uncapped: pathological update ran %v before failing on its own (%v)", sanityElapsed, sanityErr) |
| + |
| + // The actual assertion: a fresh document for the same instance, |
| + // wired through the exported Handle so this exercises the real |
| + // Apply()/rebuild() path, given a deadline far shorter than the |
| + // update's natural running time, returns promptly instead of running |
| + // to completion — proving WithCloseOnContextDone(true) actually |
| + // terminates the in-flight call rather than merely abandoning the |
| + // caller's wait. |
| + docHandle2, err := createDoc(ctx, mod, "trial-1133b", 2_000_000+11330) |
| + if err != nil { |
| + t.Fatalf("creating the second document under test: %v", err) |
| + } |
| + handle := &Handle{rt: rt, guid: "trial-1133b", clientID: 2_000_000 + 11330, mod: mod, handle: docHandle2} |
| + oldMod := handle.mod |
| + |
| + const shortDeadline = 300 * time.Millisecond |
| + capCtx, capCancel := context.WithTimeout(ctx, shortDeadline) |
| + defer capCancel() |
| + |
| + start := time.Now() |
| + applyErr := handle.Apply(capCtx, mutated) |
| + elapsed := time.Since(start) |
| + |
| + if applyErr == nil { |
| + t.Fatalf("expected the capped Apply to fail (it should have been cut off before completing), got nil error after %v", elapsed) |
| + } |
| + // Generous upper bound: the call must not have been allowed to run |
| + // anywhere near its natural ~4.36s completion time. shortDeadline |
| + // (300ms) plus slack for scheduling/cleanup overhead. |
| + const upperBound = 2 * time.Second |
| + if elapsed > upperBound { |
| + t.Fatalf("Apply took %v with a %v deadline (natural runtime is ~4.36s) — "+ |
| + "WithCloseOnContextDone did not actually cut the call off", elapsed, shortDeadline) |
| + } |
| + t.Logf("capped: Apply returned after %v (deadline %v, uncapped natural runtime %v): %v", |
| + elapsed, shortDeadline, sanityElapsed, applyErr) |
| + |
| + // The instance must have been discarded and rebuilt: a fresh module, |
| + // not the one that was mid-flight when the deadline hit. |
| + if handle.mod == oldMod { |
| + t.Fatalf("handle.mod is unchanged after a fatal (timed-out) Apply — the poisoned instance was not discarded") |
| + } |
| + |
| + // And the rebuilt instance must actually work: apply a real update |
| + // and read it back, proving "a fresh instance for the same document |
| + // rebuilds correctly" isn't just an unused module sitting there. |
| + hello := loadFixtureBytes(t, "hello") |
| + if err := handle.Apply(ctx, hello); err != nil { |
| + t.Fatalf("instance unusable after timeout-triggered rebuild: %v", err) |
| + } |
| + got, err := handle.Text(ctx) |
| + if err != nil { |
| + t.Fatalf("Text after recovery: %v", err) |
| + } |
| + if got != "hello world" { |
| + t.Errorf("after timeout recovery got %q, want %q", got, "hello world") |
| + } |
| + |
| + if err := handle.Close(ctx); err != nil { |
| + t.Logf("handle.Close: %v (non-fatal for this test)", err) |
| + } |
| +} |
diff --git a/internal/core/core_test.go b/internal/core/core_test.go| new file mode 100644 |
| index 0000000..66bee5d |
| --- /dev/null |
| +++ b/internal/core/core_test.go |
| @@ -0,0 +1,173 @@ |
| +package core_test |
| + |
| +import ( |
| + "context" |
| + "encoding/hex" |
| + "encoding/json" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + |
| + "github.com/carlosframework/docs/internal/core" |
| +) |
| + |
| +func fixture(t *testing.T, name string) ([]byte, string) { |
| + t.Helper() |
| + dir := filepath.Join("..", "..", "fixtures", "data") |
| + update, err := os.ReadFile(filepath.Join(dir, name+".update.bin")) |
| + if err != nil { |
| + t.Fatalf("reading fixture: %v", err) |
| + } |
| + raw, err := os.ReadFile(filepath.Join(dir, name+".expected.json")) |
| + if err != nil { |
| + t.Fatalf("reading expectation: %v", err) |
| + } |
| + var want struct { |
| + Text string `json:"text"` |
| + } |
| + if err := json.Unmarshal(raw, &want); err != nil { |
| + t.Fatalf("parsing expectation: %v", err) |
| + } |
| + return update, want.Text |
| +} |
| + |
| +func TestAppliesFixturesUnderWazero(t *testing.T) { |
| + ctx := context.Background() |
| + rt, err := core.New(ctx) |
| + if err != nil { |
| + t.Fatalf("core.New: %v", err) |
| + } |
| + t.Cleanup(func() { rt.Close(ctx) }) |
| + |
| + for _, name := range []string{"hello", "unicode", "formatted", "concurrent"} { |
| + t.Run(name, func(t *testing.T) { |
| + update, want := fixture(t, name) |
| + doc, err := rt.NewDoc(ctx, "go-host-test-guid", 9001) |
| + if err != nil { |
| + t.Fatalf("NewDoc: %v", err) |
| + } |
| + defer doc.Close(ctx) |
| + |
| + if err := doc.Apply(ctx, update); err != nil { |
| + t.Fatalf("Apply: %v", err) |
| + } |
| + got, err := doc.Text(ctx) |
| + if err != nil { |
| + t.Fatalf("Text: %v", err) |
| + } |
| + if got != want { |
| + t.Errorf("got %q, want %q", got, want) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestGarbageUpdateReturnsErrorNotTrap(t *testing.T) { |
| + ctx := context.Background() |
| + rt, err := core.New(ctx) |
| + if err != nil { |
| + t.Fatalf("core.New: %v", err) |
| + } |
| + t.Cleanup(func() { rt.Close(ctx) }) |
| + |
| + doc, err := rt.NewDoc(ctx, "go-host-test-guid", 9001) |
| + if err != nil { |
| + t.Fatalf("NewDoc: %v", err) |
| + } |
| + defer doc.Close(ctx) |
| + |
| + if err := doc.Apply(ctx, []byte{0xff, 0xff, 0xff, 0xff}); err == nil { |
| + t.Fatal("expected an error for garbage input, got nil") |
| + } |
| + |
| + // The instance must still be usable: an error is not a trap. |
| + update, want := fixture(t, "hello") |
| + if err := doc.Apply(ctx, update); err != nil { |
| + t.Fatalf("instance unusable after a rejected update: %v", err) |
| + } |
| + got, _ := doc.Text(ctx) |
| + if got != want { |
| + t.Errorf("after recovery got %q, want %q", got, want) |
| + } |
| +} |
| + |
| +// TestTrappedInstanceIsDiscardedAndRebuilds is the test the plan's |
| +// containment argument depends on. §5.2's design is: a trap poisons the |
| +// module instance's linear memory, so the host must discard that instance |
| +// and rebuild the document rather than reuse it. That claim is only real |
| +// if this package actually does it — a host that quietly kept using a |
| +// trapped instance would make the containment guarantee theoretical. |
| +// |
| +// The trapping input below is |
| +// core/tests/wasm_trap_containment.rs's confirmed repro for |
| +// borrow_leak_repro_trapping_unicode_mutation: a 3-byte-flipped mutation |
| +// of the `unicode` fixture that reliably yields |
| +// TrapCode(MemoryOutOfBounds) reaching the from_utf8_unchecked call this |
| +// package's doc comment describes. It is not a length/content mismatch |
| +// this ABI catches cleanly (unlike TestGarbageUpdateReturnsErrorNotTrap's |
| +// 0xff bytes) — it is a genuine wasm trap, confirmed here under wazero |
| +// itself (not just wasmi, which core/tests/wasm_trap_containment.rs uses). |
| +func TestTrappedInstanceIsDiscardedAndRebuilds(t *testing.T) { |
| + ctx := context.Background() |
| + rt, err := core.New(ctx) |
| + if err != nil { |
| + t.Fatalf("core.New: %v", err) |
| + } |
| + t.Cleanup(func() { rt.Close(ctx) }) |
| + |
| + doc, err := rt.NewDoc(ctx, "go-host-test-guid", 9001) |
| + if err != nil { |
| + t.Fatalf("NewDoc: %v", err) |
| + } |
| + defer doc.Close(ctx) |
| + |
| + // Establish real content before the trap, so recovery can be checked |
| + // against more than an empty document: a rebuild that silently lost |
| + // this would be a much worse failure than one that merely can't apply |
| + // anything further. |
| + helloUpdate, helloWant := fixture(t, "hello") |
| + if err := doc.Apply(ctx, helloUpdate); err != nil { |
| + t.Fatalf("seeding the document before the trap: %v", err) |
| + } |
| + |
| + trapHex := "01010100040107636f6e74656e741168c3a96c6c6f20f09f8c8d206361ffc3a900" |
| + trapping, err := hex.DecodeString(trapHex) |
| + if err != nil { |
| + t.Fatalf("decoding the trap reproducer: %v", err) |
| + } |
| + |
| + if err := doc.Apply(ctx, trapping); err == nil { |
| + t.Fatal("expected the known trap reproducer to fail — if this now succeeds, it stopped " + |
| + "trapping and needs replacing (see core/tests/wasm_trap_containment.rs)") |
| + } |
| + |
| + // The instance must have been discarded and a fresh one rebuilt from |
| + // the update log — not merely "still answers something", but holding |
| + // exactly the content applied before the trap. |
| + got, err := doc.Text(ctx) |
| + if err != nil { |
| + t.Fatalf("Text after the trap: %v (a poisoned instance reused instead of discarded would fail here)", err) |
| + } |
| + if got != helloWant { |
| + t.Fatalf("after trap recovery got %q, want %q — the rebuilt instance lost pre-trap content", got, helloWant) |
| + } |
| + |
| + // And the rebuilt instance must be fully usable going forward, not |
| + // just readable: apply a further update and confirm it merges in. |
| + unicodeUpdate, unicodeWant := fixture(t, "unicode") |
| + doc2, err := rt.NewDoc(ctx, "go-host-test-guid-2", 9002) |
| + if err != nil { |
| + t.Fatalf("NewDoc for a second, unrelated document: %v", err) |
| + } |
| + defer doc2.Close(ctx) |
| + if err := doc2.Apply(ctx, unicodeUpdate); err != nil { |
| + t.Fatalf("applying to an unrelated document after another document's trap: %v", err) |
| + } |
| + got2, err := doc2.Text(ctx) |
| + if err != nil { |
| + t.Fatalf("Text on the unrelated document: %v", err) |
| + } |
| + if got2 != unicodeWant { |
| + t.Errorf("unrelated document got %q, want %q — a trap in one document's instance must not affect another's", got2, unicodeWant) |
| + } |
| +} |
diff --git a/internal/core/testdata/docs_core.wasm b/internal/core/testdata/docs_core.wasm| new file mode 100755 |
| index 0000000..db12991 |
| Binary files /dev/null and b/internal/core/testdata/docs_core.wasm differ |