package core // White-box (package core) tests for behaviour the public API alone can't // prove: // // - TestTrappedInstanceIsDiscardedAndRebuilds needs to check the module // instance itself changed (fix-round-1 finding 1: this specific trap // fires before any RefCell is borrowed, so a host that never discarded // anything would pass every check the external core_test package can // make). // - TestSlowUpdateIsCutOffByGuestCallTimeout needs the unexported // Runtime.instantiate/createDoc/applyOn helpers directly, and // constructs a Handle by hand, because reproducing a genuinely slow // guest call requires state the public API deliberately never exposes: // a *shared* module instance carrying the accumulated linear memory of // many prior documents. // // # Why the timeout test replays a real fuzz sequence rather than just // asserting a slow call gets cut off // // 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, and confirms // that under wazero's compiler engine this input is still genuinely slow // (single-digit seconds, not milliseconds) when left uncapped — measured // at ~4.34s, which is why defaultGuestCallTimeout is 1s and not the // originally-chosen 5s (fix-round-1 finding 2): 5s barely — and // unreliably — beats a 4.34s natural completion; it does not actually // demonstrate the cap doing anything for its own motivating case. import ( "context" "encoding/hex" "encoding/json" "errors" "fmt" "os" "path/filepath" "testing" "time" "github.com/tetratelabs/wazero/api" ) // 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 loadFixtureExpectedText(t *testing.T, name string) string { t.Helper() raw, err := os.ReadFile(filepath.Join("..", "..", "fixtures", "data", name+".expected.json")) if err != nil { t.Fatalf("reading expectation for %s: %v", name, err) } var want struct { Text string `json:"text"` } if err := json.Unmarshal(raw, &want); err != nil { t.Fatalf("parsing expectation for %s: %v", name, err) } return want.Text } // loadFragmentFixtureBytes/loadFragmentFixtureExpectedText are // loadFixtureBytes/loadFixtureExpectedText's counterparts over // fixtures/vectors/fragment instead of fixtures/data — a real Yjs // update building a Y.XmlFragment on root "prosemirror" // (fixtures/generate-fragment.mjs's "trap-containment vectors") // rather than a bare Y.Text on root "content". // // Used only where a test in this file reads content back through // Handle.Text and checks it against a real string: Text now reads // fragment_text off root "prosemirror" (core/src/abi.rs's // text_string doc comment), so fixtures/data's updates — which apply // cleanly but touch a root Text no longer reads — would read back "" // there, same silent-empty trap this whole fix round exists to close. // // Deliberately NOT used for TestSlowUpdateIsCutOffByGuestCallTimeout's // own fixtureNames/fixtures RNG-mutation loop below: that loop's // wantHex assertion pins an exact mutated byte sequence discovered by // walking a fixed-seed PRNG over fixtures/data's specific byte // content at a specific trial index. Swapping in different-content, // different-length fragment fixtures there would silently walk the // same RNG sequence to different bytes and break that reproducer, // not fix anything — this pair exists only for the places that read // content back and check it, not for that fixed-sequence mutation // input. func loadFragmentFixtureBytes(t *testing.T, name string) []byte { t.Helper() b, err := os.ReadFile(filepath.Join("..", "..", "fixtures", "vectors", "fragment", name+".update.bin")) if err != nil { t.Fatalf("reading fragment fixture %s: %v", name, err) } return b } func loadFragmentFixtureExpectedText(t *testing.T, name string) string { t.Helper() raw, err := os.ReadFile(filepath.Join("..", "..", "fixtures", "vectors", "fragment", name+".expected.json")) if err != nil { t.Fatalf("reading expectation for fragment fixture %s: %v", name, err) } var want struct { Text string `json:"text"` } if err := json.Unmarshal(raw, &want); err != nil { t.Fatalf("parsing expectation for fragment fixture %s: %v", name, err) } return want.Text } // 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. // // Fix-round-1 finding 1: an earlier version of this test (in the external // core_test package) only checked behaviour — that Apply reports an error // and a later Text still returns the right content — and the reviewer // showed that passes identically against a host that simply keeps reusing // the trapped instance, because this specific reproducer traps inside // Update::decode_v1 before any RefCell is borrowed: nothing about *this* // trap corrupts a reused instance enough to fail those checks. The // assertion that can actually fail if the discard is removed is a direct // check that h.mod is a different value afterward — which needs // unexported access, hence this file rather than core_test.go. func TestTrappedInstanceIsDiscardedAndRebuilds(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) }) 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 := loadFragmentFixtureBytes(t, "hello") helloWant := loadFragmentFixtureExpectedText(t, "hello") if err := doc.Apply(ctx, helloUpdate); err != nil { t.Fatalf("seeding the document before the trap: %v", err) } // Captured before the trap: this is the actual thing under test. oldMod := doc.mod // 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. Confirmed here under wazero // itself (not just wasmi, which that Rust test uses). trapHex := "01010100040107636f6e74656e741168c3a96c6c6f20f09f8c8d206361ffc3a900" trapping, err := hex.DecodeString(trapHex) if err != nil { t.Fatalf("decoding the trap reproducer: %v", err) } applyErr := doc.Apply(ctx, trapping) if applyErr == 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)") } if !Recovered(applyErr) { t.Errorf("expected a trap whose instance was successfully rebuilt to report Recovered(err) == true, got err = %v", applyErr) } // The check that actually distinguishes "discarded and rebuilt" from // "reused, and happened not to break for this particular trap": the // module instance itself must be a different value. if doc.mod == oldMod { t.Fatalf("doc.mod is unchanged after a trapping Apply — the poisoned instance was not discarded " + "(this specific trap does not corrupt a reused instance, so every behavioural check below would " + "pass even without discard — that is the whole point of this check)") } // 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", 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 := loadFragmentFixtureBytes(t, "unicode") unicodeWant := loadFragmentFixtureExpectedText(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) } } // TestGuestCallTimeoutOption confirms New defaults to // defaultGuestCallTimeout and that WithGuestCallTimeout actually overrides // it (fix-round-1 finding 2's "tunable without a recompile"). Deliberately // cheap: this checks the field is wired correctly, not the cap firing // under load — TestSlowUpdateIsCutOffByGuestCallTimeout below covers that. func TestGuestCallTimeoutOption(t *testing.T) { ctx := context.Background() rt, err := New(ctx) if err != nil { t.Fatalf("core.New: %v", err) } if rt.callTimeout != defaultGuestCallTimeout { t.Errorf("default callTimeout = %v, want %v", rt.callTimeout, defaultGuestCallTimeout) } rt.Close(ctx) const override = 250 * time.Millisecond rt2, err := New(ctx, WithGuestCallTimeout(override)) if err != nil { t.Fatalf("core.New with WithGuestCallTimeout: %v", err) } defer rt2.Close(ctx) if rt2.callTimeout != override { t.Errorf("callTimeout after WithGuestCallTimeout(%v) = %v, want %v", override, rt2.callTimeout, override) } } func TestSlowUpdateIsCutOffByGuestCallTimeout(t *testing.T) { ctx := context.Background() rt, err := New(ctx) // default 1s callTimeout — this is the setting under test. if err != nil { t.Fatalf("core.New: %v", err) } t.Cleanup(func() { rt.Close(ctx) }) if rt.callTimeout != defaultGuestCallTimeout { t.Fatalf("expected the default callTimeout (%v), got %v", defaultGuestCallTimeout, rt.callTimeout) } warm := func() (api.Module, uint64, []byte) { t.Helper() 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, rt.callTimeout, 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, rt.callTimeout, 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, rt.callTimeout, mod, "trial-1133", 2_000_000+1133) if err != nil { t.Fatalf("creating the document under test: %v", err) } return mod, docHandle, mutated } mod, docHandle, mutated := warm() // 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 — // deliberately not rt.callTimeout, since this call needs to be allowed // to run past it to measure the natural time. Measured while building // this test: ~4.34s (vs ~3ms with no memory growth, and 163s under // wasmi's interpreter on the equivalent state). sanityCtx, sanityCancel := context.WithTimeout(ctx, 20*time.Second) sanityStart := time.Now() sanityErr := applyOn(sanityCtx, 20*time.Second, 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.34s 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("sanity probe: pathological update failed or reached its 20s bound after %v (%v)", sanityElapsed, sanityErr) _ = mod.Close(ctx) // Repeat the exact warm-up independently: a timed-out sanity probe closes // its module, and a fresh but unwarmed module would lose the reproducer. mod, docHandle, mutated = warm() handle := &Handle{rt: rt, guid: "trial-1133", clientID: 2_000_000 + 1133, mod: mod, handle: docHandle} oldMod := handle.mod start := time.Now() applyErr := handle.Apply(ctx, mutated) elapsed := time.Since(start) if !errors.Is(applyErr, context.DeadlineExceeded) { t.Fatalf("expected the capped Apply to reach its deadline, got %v after %v", applyErr, elapsed) } // Generous upper bound: the call must not have been allowed to run // anywhere near its natural ~4.34s completion time. const upperBound = 3 * time.Second if elapsed > upperBound { t.Fatalf("Apply took %v against a %v default cap (natural runtime is ~4.34s) — "+ "WithCloseOnContextDone did not actually cut the call off", elapsed, defaultGuestCallTimeout) } t.Logf("capped: Apply returned after %v (default cap %v, uncapped natural runtime %v): %v", elapsed, defaultGuestCallTimeout, 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") } // This document had nothing in its log (its own first Apply is the one // that just timed out), so rebuild had nothing to replay and should // always succeed — meaning this timeout, too, should come back as // Recovered. if !Recovered(applyErr) { t.Errorf("expected a timed-out Apply whose instance was successfully rebuilt to report Recovered(err) == true, got err = %v", applyErr) } // 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. // Fragment fixture, not the RNG loop's fixtures/data one: this // checks Handle.Text's real content, which now reads root // "prosemirror" (see loadFragmentFixtureBytes's own doc comment). hello := loadFragmentFixtureBytes(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) } } // TestApplyLogCapFailsCleanly is fix-round-1 finding 5: h.applied must not // grow without limit. Rather than spend real time performing // maxRetainedUpdates genuine applies, this constructs a Handle whose log // is already at the cap (a legitimate white-box shortcut — what's under // test is the length check in Apply, not how the log got that long) and // confirms a further Apply is refused cleanly, without growing the log or // touching the wasm module at all. func TestApplyLogCapFailsCleanly(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) }) doc, err := rt.NewDoc(ctx, "log-cap-test-guid", 1) if err != nil { t.Fatalf("NewDoc: %v", err) } defer doc.Close(ctx) doc.applied = make([][]byte, maxRetainedUpdates) hello := loadFixtureBytes(t, "hello") err = doc.Apply(ctx, hello) if !errors.Is(err, ErrUpdateLogFull) { t.Fatalf("expected ErrUpdateLogFull once the log is at its cap, got %v", err) } if len(doc.applied) != maxRetainedUpdates { t.Fatalf("log grew past the cap: len(doc.applied) = %d, want %d", len(doc.applied), maxRetainedUpdates) } // And the instance itself must be untouched: Apply must refuse before // ever calling into wasm, not after a wasted (and possibly poisoning) // attempt. text, err := doc.Text(ctx) if err != nil { t.Fatalf("Text after a log-cap refusal: %v", err) } if text != "" { t.Errorf("expected an untouched (empty) document after a log-cap refusal, got %q", text) } } // checkABIVersion is the pure decision instantiate defers to. Fix round 1 // on Task 2: the previous version of this coverage (TestABIVersionIsPinnedAtTwo // in core_test.go) only ever called core.New, which never reaches this // check at all — deleting the check outright still left that test passing. // These cases exercise the decision directly, with no compiled wasm module // standing in for "reported version". func TestCheckABIVersion(t *testing.T) { cases := []struct { name string reported uint32 want uint32 wantErr bool }{ {name: "equal", reported: 2, want: 2, wantErr: false}, {name: "reported lower", reported: 1, want: 2, wantErr: true}, {name: "reported higher", reported: 3, want: 2, wantErr: true}, {name: "reported zero", reported: 0, want: 2, wantErr: true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { err := checkABIVersion(tc.reported, tc.want) if tc.wantErr && err == nil { t.Fatalf("checkABIVersion(%d, %d) = nil, want an error", tc.reported, tc.want) } if !tc.wantErr && err != nil { t.Fatalf("checkABIVersion(%d, %d) = %v, want nil", tc.reported, tc.want, err) } }) } } // The error text is part of the contract, not incidental: whoever reads // it in production needs both numbers to tell which side is stale. func TestCheckABIVersionErrorNamesBothNumbers(t *testing.T) { err := checkABIVersion(5, 2) if err == nil { t.Fatalf("checkABIVersion(5, 2) = nil, want an error") } want := "core module reports ABI 5, host understands 2" if err.Error() != want { t.Errorf("checkABIVersion(5, 2).Error() = %q, want %q", err.Error(), want) } }