| 1 | package core |
| 2 | |
| 3 | // White-box (package core) tests for behaviour the public API alone can't |
| 4 | // prove: |
| 5 | // |
| 6 | // - TestTrappedInstanceIsDiscardedAndRebuilds needs to check the module |
| 7 | // instance itself changed (fix-round-1 finding 1: this specific trap |
| 8 | // fires before any RefCell is borrowed, so a host that never discarded |
| 9 | // anything would pass every check the external core_test package can |
| 10 | // make). |
| 11 | // - TestSlowUpdateIsCutOffByGuestCallTimeout needs the unexported |
| 12 | // Runtime.instantiate/createDoc/applyOn helpers directly, and |
| 13 | // constructs a Handle by hand, because reproducing a genuinely slow |
| 14 | // guest call requires state the public API deliberately never exposes: |
| 15 | // a *shared* module instance carrying the accumulated linear memory of |
| 16 | // many prior documents. |
| 17 | // |
| 18 | // # Why the timeout test replays a real fuzz sequence rather than just |
| 19 | // asserting a slow call gets cut off |
| 20 | // |
| 21 | // Task 5's fuzzing (core/tests/wasm_trap_containment.rs) found that |
| 22 | // mutating the `concurrent` fixture with a fixed xorshift64 RNG (seed |
| 23 | // 0xC0FFEE) and applying the mutation at trial index 1133 of a 1500-trial |
| 24 | // run produced a 36-byte update that ran docs_core_apply for 163 real CPU |
| 25 | // seconds under wasmi before returning an ordinary Ok — not a trap, a |
| 26 | // pathologically slow decode. That 163-second number is specific to |
| 27 | // wasmi's interpreter and to the *cumulative* linear-memory growth of the |
| 28 | // 1133 documents created before it in that run (confirmed empirically |
| 29 | // while building this test: the identical 36 bytes applied to a *fresh* |
| 30 | // instance under wazero's compiler engine traps in ~3ms instead of running |
| 31 | // long, and merely inflating memory with large alloc/free calls to a |
| 32 | // comparable size does not reproduce the slowdown either — it depends on |
| 33 | // the specific accumulated document state, not just the byte count of |
| 34 | // memory grown). |
| 35 | // |
| 36 | // So this test replays the exact same deterministic sequence — same RNG, |
| 37 | // same mutate(), same fixture cycling, same trial count — against a real |
| 38 | // module instance to reach the same state, confirms trial 1133 reproduces |
| 39 | // the exact bytes documented in wasm_trap_containment.rs, and confirms |
| 40 | // that under wazero's compiler engine this input is still genuinely slow |
| 41 | // (single-digit seconds, not milliseconds) when left uncapped — measured |
| 42 | // at ~4.34s, which is why defaultGuestCallTimeout is 1s and not the |
| 43 | // originally-chosen 5s (fix-round-1 finding 2): 5s barely — and |
| 44 | // unreliably — beats a 4.34s natural completion; it does not actually |
| 45 | // demonstrate the cap doing anything for its own motivating case. |
| 46 | |
| 47 | import ( |
| 48 | "context" |
| 49 | "encoding/hex" |
| 50 | "encoding/json" |
| 51 | "errors" |
| 52 | "fmt" |
| 53 | "os" |
| 54 | "path/filepath" |
| 55 | "testing" |
| 56 | "time" |
| 57 | |
| 58 | "github.com/tetratelabs/wazero/api" |
| 59 | ) |
| 60 | |
| 61 | // rng and mutate below are a byte-for-byte port of |
| 62 | // core/tests/wasm_trap_containment.rs's Rng/mutate — same xorshift64 |
| 63 | // stream, same 1-3 byte-flip mutation — so that seeding with the same |
| 64 | // 0xC0FFEE seed reproduces the exact same trial sequence. |
| 65 | type rng struct{ x uint64 } |
| 66 | |
| 67 | func (r *rng) nextU64() uint64 { |
| 68 | x := r.x |
| 69 | x ^= x << 13 |
| 70 | x ^= x >> 7 |
| 71 | x ^= x << 17 |
| 72 | r.x = x |
| 73 | return x |
| 74 | } |
| 75 | |
| 76 | func (r *rng) nextUsize(bound int) int { |
| 77 | return int(r.nextU64() % uint64(bound)) |
| 78 | } |
| 79 | |
| 80 | func mutate(r *rng, base []byte) []byte { |
| 81 | data := make([]byte, len(base)) |
| 82 | copy(data, base) |
| 83 | nFlips := 1 + r.nextUsize(3) |
| 84 | for i := 0; i < nFlips; i++ { |
| 85 | if len(data) == 0 { |
| 86 | break |
| 87 | } |
| 88 | idx := r.nextUsize(len(data)) |
| 89 | data[idx] ^= byte(1 + r.nextUsize(255)) |
| 90 | } |
| 91 | return data |
| 92 | } |
| 93 | |
| 94 | func loadFixtureBytes(t *testing.T, name string) []byte { |
| 95 | t.Helper() |
| 96 | b, err := os.ReadFile(filepath.Join("..", "..", "fixtures", "data", name+".update.bin")) |
| 97 | if err != nil { |
| 98 | t.Fatalf("reading fixture %s: %v", name, err) |
| 99 | } |
| 100 | return b |
| 101 | } |
| 102 | |
| 103 | func loadFixtureExpectedText(t *testing.T, name string) string { |
| 104 | t.Helper() |
| 105 | raw, err := os.ReadFile(filepath.Join("..", "..", "fixtures", "data", name+".expected.json")) |
| 106 | if err != nil { |
| 107 | t.Fatalf("reading expectation for %s: %v", name, err) |
| 108 | } |
| 109 | var want struct { |
| 110 | Text string `json:"text"` |
| 111 | } |
| 112 | if err := json.Unmarshal(raw, &want); err != nil { |
| 113 | t.Fatalf("parsing expectation for %s: %v", name, err) |
| 114 | } |
| 115 | return want.Text |
| 116 | } |
| 117 | |
| 118 | // loadFragmentFixtureBytes/loadFragmentFixtureExpectedText are |
| 119 | // loadFixtureBytes/loadFixtureExpectedText's counterparts over |
| 120 | // fixtures/vectors/fragment instead of fixtures/data — a real Yjs |
| 121 | // update building a Y.XmlFragment on root "prosemirror" |
| 122 | // (fixtures/generate-fragment.mjs's "trap-containment vectors") |
| 123 | // rather than a bare Y.Text on root "content". |
| 124 | // |
| 125 | // Used only where a test in this file reads content back through |
| 126 | // Handle.Text and checks it against a real string: Text now reads |
| 127 | // fragment_text off root "prosemirror" (core/src/abi.rs's |
| 128 | // text_string doc comment), so fixtures/data's updates — which apply |
| 129 | // cleanly but touch a root Text no longer reads — would read back "" |
| 130 | // there, same silent-empty trap this whole fix round exists to close. |
| 131 | // |
| 132 | // Deliberately NOT used for TestSlowUpdateIsCutOffByGuestCallTimeout's |
| 133 | // own fixtureNames/fixtures RNG-mutation loop below: that loop's |
| 134 | // wantHex assertion pins an exact mutated byte sequence discovered by |
| 135 | // walking a fixed-seed PRNG over fixtures/data's specific byte |
| 136 | // content at a specific trial index. Swapping in different-content, |
| 137 | // different-length fragment fixtures there would silently walk the |
| 138 | // same RNG sequence to different bytes and break that reproducer, |
| 139 | // not fix anything — this pair exists only for the places that read |
| 140 | // content back and check it, not for that fixed-sequence mutation |
| 141 | // input. |
| 142 | func loadFragmentFixtureBytes(t *testing.T, name string) []byte { |
| 143 | t.Helper() |
| 144 | b, err := os.ReadFile(filepath.Join("..", "..", "fixtures", "vectors", "fragment", name+".update.bin")) |
| 145 | if err != nil { |
| 146 | t.Fatalf("reading fragment fixture %s: %v", name, err) |
| 147 | } |
| 148 | return b |
| 149 | } |
| 150 | |
| 151 | func loadFragmentFixtureExpectedText(t *testing.T, name string) string { |
| 152 | t.Helper() |
| 153 | raw, err := os.ReadFile(filepath.Join("..", "..", "fixtures", "vectors", "fragment", name+".expected.json")) |
| 154 | if err != nil { |
| 155 | t.Fatalf("reading expectation for fragment fixture %s: %v", name, err) |
| 156 | } |
| 157 | var want struct { |
| 158 | Text string `json:"text"` |
| 159 | } |
| 160 | if err := json.Unmarshal(raw, &want); err != nil { |
| 161 | t.Fatalf("parsing expectation for fragment fixture %s: %v", name, err) |
| 162 | } |
| 163 | return want.Text |
| 164 | } |
| 165 | |
| 166 | // TestTrappedInstanceIsDiscardedAndRebuilds is the test the plan's |
| 167 | // containment argument depends on. §5.2's design is: a trap poisons the |
| 168 | // module instance's linear memory, so the host must discard that instance |
| 169 | // and rebuild the document rather than reuse it. That claim is only real |
| 170 | // if this package actually does it. |
| 171 | // |
| 172 | // Fix-round-1 finding 1: an earlier version of this test (in the external |
| 173 | // core_test package) only checked behaviour — that Apply reports an error |
| 174 | // and a later Text still returns the right content — and the reviewer |
| 175 | // showed that passes identically against a host that simply keeps reusing |
| 176 | // the trapped instance, because this specific reproducer traps inside |
| 177 | // Update::decode_v1 before any RefCell is borrowed: nothing about *this* |
| 178 | // trap corrupts a reused instance enough to fail those checks. The |
| 179 | // assertion that can actually fail if the discard is removed is a direct |
| 180 | // check that h.mod is a different value afterward — which needs |
| 181 | // unexported access, hence this file rather than core_test.go. |
| 182 | func TestTrappedInstanceIsDiscardedAndRebuilds(t *testing.T) { |
| 183 | ctx := context.Background() |
| 184 | rt, err := New(ctx) |
| 185 | if err != nil { |
| 186 | t.Fatalf("core.New: %v", err) |
| 187 | } |
| 188 | t.Cleanup(func() { rt.Close(ctx) }) |
| 189 | |
| 190 | doc, err := rt.NewDoc(ctx, "go-host-test-guid", 9001) |
| 191 | if err != nil { |
| 192 | t.Fatalf("NewDoc: %v", err) |
| 193 | } |
| 194 | defer doc.Close(ctx) |
| 195 | |
| 196 | // Establish real content before the trap, so recovery can be checked |
| 197 | // against more than an empty document: a rebuild that silently lost |
| 198 | // this would be a much worse failure than one that merely can't apply |
| 199 | // anything further. |
| 200 | helloUpdate := loadFragmentFixtureBytes(t, "hello") |
| 201 | helloWant := loadFragmentFixtureExpectedText(t, "hello") |
| 202 | if err := doc.Apply(ctx, helloUpdate); err != nil { |
| 203 | t.Fatalf("seeding the document before the trap: %v", err) |
| 204 | } |
| 205 | |
| 206 | // Captured before the trap: this is the actual thing under test. |
| 207 | oldMod := doc.mod |
| 208 | |
| 209 | // core/tests/wasm_trap_containment.rs's confirmed repro for |
| 210 | // borrow_leak_repro_trapping_unicode_mutation: a 3-byte-flipped |
| 211 | // mutation of the `unicode` fixture that reliably yields |
| 212 | // TrapCode(MemoryOutOfBounds) reaching the from_utf8_unchecked call |
| 213 | // this package's doc comment describes. Confirmed here under wazero |
| 214 | // itself (not just wasmi, which that Rust test uses). |
| 215 | trapHex := "01010100040107636f6e74656e741168c3a96c6c6f20f09f8c8d206361ffc3a900" |
| 216 | trapping, err := hex.DecodeString(trapHex) |
| 217 | if err != nil { |
| 218 | t.Fatalf("decoding the trap reproducer: %v", err) |
| 219 | } |
| 220 | |
| 221 | applyErr := doc.Apply(ctx, trapping) |
| 222 | if applyErr == nil { |
| 223 | t.Fatal("expected the known trap reproducer to fail — if this now succeeds, it stopped " + |
| 224 | "trapping and needs replacing (see core/tests/wasm_trap_containment.rs)") |
| 225 | } |
| 226 | if !Recovered(applyErr) { |
| 227 | t.Errorf("expected a trap whose instance was successfully rebuilt to report Recovered(err) == true, got err = %v", applyErr) |
| 228 | } |
| 229 | |
| 230 | // The check that actually distinguishes "discarded and rebuilt" from |
| 231 | // "reused, and happened not to break for this particular trap": the |
| 232 | // module instance itself must be a different value. |
| 233 | if doc.mod == oldMod { |
| 234 | t.Fatalf("doc.mod is unchanged after a trapping Apply — the poisoned instance was not discarded " + |
| 235 | "(this specific trap does not corrupt a reused instance, so every behavioural check below would " + |
| 236 | "pass even without discard — that is the whole point of this check)") |
| 237 | } |
| 238 | |
| 239 | // The instance must have been discarded and a fresh one rebuilt from |
| 240 | // the update log — not merely "still answers something", but holding |
| 241 | // exactly the content applied before the trap. |
| 242 | got, err := doc.Text(ctx) |
| 243 | if err != nil { |
| 244 | t.Fatalf("Text after the trap: %v", err) |
| 245 | } |
| 246 | if got != helloWant { |
| 247 | t.Fatalf("after trap recovery got %q, want %q — the rebuilt instance lost pre-trap content", got, helloWant) |
| 248 | } |
| 249 | |
| 250 | // And the rebuilt instance must be fully usable going forward, not |
| 251 | // just readable: apply a further update and confirm it merges in. |
| 252 | unicodeUpdate := loadFragmentFixtureBytes(t, "unicode") |
| 253 | unicodeWant := loadFragmentFixtureExpectedText(t, "unicode") |
| 254 | doc2, err := rt.NewDoc(ctx, "go-host-test-guid-2", 9002) |
| 255 | if err != nil { |
| 256 | t.Fatalf("NewDoc for a second, unrelated document: %v", err) |
| 257 | } |
| 258 | defer doc2.Close(ctx) |
| 259 | if err := doc2.Apply(ctx, unicodeUpdate); err != nil { |
| 260 | t.Fatalf("applying to an unrelated document after another document's trap: %v", err) |
| 261 | } |
| 262 | got2, err := doc2.Text(ctx) |
| 263 | if err != nil { |
| 264 | t.Fatalf("Text on the unrelated document: %v", err) |
| 265 | } |
| 266 | if got2 != unicodeWant { |
| 267 | t.Errorf("unrelated document got %q, want %q — a trap in one document's instance must not affect another's", got2, unicodeWant) |
| 268 | } |
| 269 | } |
| 270 | |
| 271 | // TestGuestCallTimeoutOption confirms New defaults to |
| 272 | // defaultGuestCallTimeout and that WithGuestCallTimeout actually overrides |
| 273 | // it (fix-round-1 finding 2's "tunable without a recompile"). Deliberately |
| 274 | // cheap: this checks the field is wired correctly, not the cap firing |
| 275 | // under load — TestSlowUpdateIsCutOffByGuestCallTimeout below covers that. |
| 276 | func TestGuestCallTimeoutOption(t *testing.T) { |
| 277 | ctx := context.Background() |
| 278 | |
| 279 | rt, err := New(ctx) |
| 280 | if err != nil { |
| 281 | t.Fatalf("core.New: %v", err) |
| 282 | } |
| 283 | if rt.callTimeout != defaultGuestCallTimeout { |
| 284 | t.Errorf("default callTimeout = %v, want %v", rt.callTimeout, defaultGuestCallTimeout) |
| 285 | } |
| 286 | rt.Close(ctx) |
| 287 | |
| 288 | const override = 250 * time.Millisecond |
| 289 | rt2, err := New(ctx, WithGuestCallTimeout(override)) |
| 290 | if err != nil { |
| 291 | t.Fatalf("core.New with WithGuestCallTimeout: %v", err) |
| 292 | } |
| 293 | defer rt2.Close(ctx) |
| 294 | if rt2.callTimeout != override { |
| 295 | t.Errorf("callTimeout after WithGuestCallTimeout(%v) = %v, want %v", override, rt2.callTimeout, override) |
| 296 | } |
| 297 | } |
| 298 | |
| 299 | func TestSlowUpdateIsCutOffByGuestCallTimeout(t *testing.T) { |
| 300 | ctx := context.Background() |
| 301 | rt, err := New(ctx) // default 1s callTimeout — this is the setting under test. |
| 302 | if err != nil { |
| 303 | t.Fatalf("core.New: %v", err) |
| 304 | } |
| 305 | t.Cleanup(func() { rt.Close(ctx) }) |
| 306 | if rt.callTimeout != defaultGuestCallTimeout { |
| 307 | t.Fatalf("expected the default callTimeout (%v), got %v", defaultGuestCallTimeout, rt.callTimeout) |
| 308 | } |
| 309 | |
| 310 | warm := func() (api.Module, uint64, []byte) { |
| 311 | t.Helper() |
| 312 | mod, err := rt.instantiate(ctx) |
| 313 | if err != nil { |
| 314 | t.Fatalf("instantiate: %v", err) |
| 315 | } |
| 316 | |
| 317 | fixtureNames := []string{"hello", "unicode", "formatted", "concurrent"} |
| 318 | fixtures := make([][]byte, len(fixtureNames)) |
| 319 | for i, name := range fixtureNames { |
| 320 | fixtures[i] = loadFixtureBytes(t, name) |
| 321 | } |
| 322 | |
| 323 | // Warm up: replay trials 0..1132 exactly as |
| 324 | // wasm_trap_containment.rs's fuzz loop does (fixture pick, mutate, |
| 325 | // doc_new, apply), ignoring the outcome of each — most are clean |
| 326 | // oks or clean rejections, and this loop must not go through |
| 327 | // Handle.Apply (which would auto-rebuild on any genuine trap here |
| 328 | // and undo the accumulated memory growth this test depends on). |
| 329 | r := &rng{x: 0xC0FFEE} |
| 330 | for i := 0; i < 1133; i++ { |
| 331 | fi := r.nextUsize(len(fixtures)) |
| 332 | mutated := mutate(r, fixtures[fi]) |
| 333 | docHandle, err := createDoc(ctx, rt.callTimeout, mod, fmt.Sprintf("warmup-%d", i), uint64(2_000_000+i)) |
| 334 | if err != nil { |
| 335 | t.Fatalf("warm-up trial %d: doc_new failed unexpectedly: %v", i, err) |
| 336 | } |
| 337 | _ = applyOn(ctx, rt.callTimeout, mod, docHandle, mutated) // outcome irrelevant; only the memory growth matters |
| 338 | } |
| 339 | |
| 340 | // Trial 1133 itself: the exact reproducer documented in |
| 341 | // core/tests/wasm_trap_containment.rs ("a 3-byte-flipped `concurrent` |
| 342 | // fixture ... ran for 163 seconds of real CPU time"). Confirm the RNG |
| 343 | // sequence still lands on the documented bytes before relying on them. |
| 344 | fi := r.nextUsize(len(fixtures)) |
| 345 | mutated := mutate(r, fixtures[fi]) |
| 346 | const wantHex = "02010200040107636f6e6f656e7403424293010100040107636f6e74656e74034141418a" |
| 347 | if got := hex.EncodeToString(mutated); got != wantHex { |
| 348 | t.Fatalf("RNG/mutate drifted from core/tests/wasm_trap_containment.rs: trial 1133 produced "+ |
| 349 | "fixture=%s bytes=%s, want the documented bytes=%s — this test no longer exercises the "+ |
| 350 | "known-slow reproducer", fixtureNames[fi], got, wantHex) |
| 351 | } |
| 352 | if fixtureNames[fi] != "concurrent" { |
| 353 | t.Fatalf("expected trial 1133 to mutate the concurrent fixture, got %s", fixtureNames[fi]) |
| 354 | } |
| 355 | |
| 356 | docHandle, err := createDoc(ctx, rt.callTimeout, mod, "trial-1133", 2_000_000+1133) |
| 357 | if err != nil { |
| 358 | t.Fatalf("creating the document under test: %v", err) |
| 359 | } |
| 360 | |
| 361 | return mod, docHandle, mutated |
| 362 | } |
| 363 | mod, docHandle, mutated := warm() |
| 364 | |
| 365 | // Sanity: confirm this input is genuinely slow (not just a fast trap) |
| 366 | // under wazero's compiler engine at this memory-growth state, with a |
| 367 | // budget generous enough to let it run to its natural conclusion — |
| 368 | // deliberately not rt.callTimeout, since this call needs to be allowed |
| 369 | // to run past it to measure the natural time. Measured while building |
| 370 | // this test: ~4.34s (vs ~3ms with no memory growth, and 163s under |
| 371 | // wasmi's interpreter on the equivalent state). |
| 372 | sanityCtx, sanityCancel := context.WithTimeout(ctx, 20*time.Second) |
| 373 | sanityStart := time.Now() |
| 374 | sanityErr := applyOn(sanityCtx, 20*time.Second, mod, docHandle, mutated) |
| 375 | sanityElapsed := time.Since(sanityStart) |
| 376 | sanityCancel() |
| 377 | if sanityErr == nil { |
| 378 | t.Fatalf("expected the known pathological update to fail (trap or time out) even given 20s, got a clean success") |
| 379 | } |
| 380 | if sanityElapsed < 200*time.Millisecond { |
| 381 | t.Fatalf("expected the known pathological update to run for at least hundreds of "+ |
| 382 | "milliseconds at this memory-growth state (it measured ~4.34s while building this test); "+ |
| 383 | "it returned in %v instead — the reproducer may have stopped reproducing, which would make "+ |
| 384 | "the capped assertion below meaningless", sanityElapsed) |
| 385 | } |
| 386 | t.Logf("sanity probe: pathological update failed or reached its 20s bound after %v (%v)", sanityElapsed, sanityErr) |
| 387 | _ = mod.Close(ctx) |
| 388 | |
| 389 | // Repeat the exact warm-up independently: a timed-out sanity probe closes |
| 390 | // its module, and a fresh but unwarmed module would lose the reproducer. |
| 391 | mod, docHandle, mutated = warm() |
| 392 | handle := &Handle{rt: rt, guid: "trial-1133", clientID: 2_000_000 + 1133, mod: mod, handle: docHandle} |
| 393 | oldMod := handle.mod |
| 394 | |
| 395 | start := time.Now() |
| 396 | applyErr := handle.Apply(ctx, mutated) |
| 397 | elapsed := time.Since(start) |
| 398 | |
| 399 | if !errors.Is(applyErr, context.DeadlineExceeded) { |
| 400 | t.Fatalf("expected the capped Apply to reach its deadline, got %v after %v", applyErr, elapsed) |
| 401 | } |
| 402 | // Generous upper bound: the call must not have been allowed to run |
| 403 | // anywhere near its natural ~4.34s completion time. |
| 404 | const upperBound = 3 * time.Second |
| 405 | if elapsed > upperBound { |
| 406 | t.Fatalf("Apply took %v against a %v default cap (natural runtime is ~4.34s) — "+ |
| 407 | "WithCloseOnContextDone did not actually cut the call off", elapsed, defaultGuestCallTimeout) |
| 408 | } |
| 409 | t.Logf("capped: Apply returned after %v (default cap %v, uncapped natural runtime %v): %v", |
| 410 | elapsed, defaultGuestCallTimeout, sanityElapsed, applyErr) |
| 411 | |
| 412 | // The instance must have been discarded and rebuilt: a fresh module, |
| 413 | // not the one that was mid-flight when the deadline hit. |
| 414 | if handle.mod == oldMod { |
| 415 | t.Fatalf("handle.mod is unchanged after a fatal (timed-out) Apply — the poisoned instance was not discarded") |
| 416 | } |
| 417 | // This document had nothing in its log (its own first Apply is the one |
| 418 | // that just timed out), so rebuild had nothing to replay and should |
| 419 | // always succeed — meaning this timeout, too, should come back as |
| 420 | // Recovered. |
| 421 | if !Recovered(applyErr) { |
| 422 | t.Errorf("expected a timed-out Apply whose instance was successfully rebuilt to report Recovered(err) == true, got err = %v", applyErr) |
| 423 | } |
| 424 | |
| 425 | // And the rebuilt instance must actually work: apply a real update |
| 426 | // and read it back, proving "a fresh instance for the same document |
| 427 | // rebuilds correctly" isn't just an unused module sitting there. |
| 428 | // Fragment fixture, not the RNG loop's fixtures/data one: this |
| 429 | // checks Handle.Text's real content, which now reads root |
| 430 | // "prosemirror" (see loadFragmentFixtureBytes's own doc comment). |
| 431 | hello := loadFragmentFixtureBytes(t, "hello") |
| 432 | if err := handle.Apply(ctx, hello); err != nil { |
| 433 | t.Fatalf("instance unusable after timeout-triggered rebuild: %v", err) |
| 434 | } |
| 435 | got, err := handle.Text(ctx) |
| 436 | if err != nil { |
| 437 | t.Fatalf("Text after recovery: %v", err) |
| 438 | } |
| 439 | if got != "hello world" { |
| 440 | t.Errorf("after timeout recovery got %q, want %q", got, "hello world") |
| 441 | } |
| 442 | |
| 443 | if err := handle.Close(ctx); err != nil { |
| 444 | t.Logf("handle.Close: %v (non-fatal for this test)", err) |
| 445 | } |
| 446 | } |
| 447 | |
| 448 | // TestApplyLogCapFailsCleanly is fix-round-1 finding 5: h.applied must not |
| 449 | // grow without limit. Rather than spend real time performing |
| 450 | // maxRetainedUpdates genuine applies, this constructs a Handle whose log |
| 451 | // is already at the cap (a legitimate white-box shortcut — what's under |
| 452 | // test is the length check in Apply, not how the log got that long) and |
| 453 | // confirms a further Apply is refused cleanly, without growing the log or |
| 454 | // touching the wasm module at all. |
| 455 | func TestApplyLogCapFailsCleanly(t *testing.T) { |
| 456 | ctx := context.Background() |
| 457 | rt, err := New(ctx) |
| 458 | if err != nil { |
| 459 | t.Fatalf("core.New: %v", err) |
| 460 | } |
| 461 | t.Cleanup(func() { rt.Close(ctx) }) |
| 462 | |
| 463 | doc, err := rt.NewDoc(ctx, "log-cap-test-guid", 1) |
| 464 | if err != nil { |
| 465 | t.Fatalf("NewDoc: %v", err) |
| 466 | } |
| 467 | defer doc.Close(ctx) |
| 468 | |
| 469 | doc.applied = make([][]byte, maxRetainedUpdates) |
| 470 | |
| 471 | hello := loadFixtureBytes(t, "hello") |
| 472 | err = doc.Apply(ctx, hello) |
| 473 | if !errors.Is(err, ErrUpdateLogFull) { |
| 474 | t.Fatalf("expected ErrUpdateLogFull once the log is at its cap, got %v", err) |
| 475 | } |
| 476 | if len(doc.applied) != maxRetainedUpdates { |
| 477 | t.Fatalf("log grew past the cap: len(doc.applied) = %d, want %d", len(doc.applied), maxRetainedUpdates) |
| 478 | } |
| 479 | |
| 480 | // And the instance itself must be untouched: Apply must refuse before |
| 481 | // ever calling into wasm, not after a wasted (and possibly poisoning) |
| 482 | // attempt. |
| 483 | text, err := doc.Text(ctx) |
| 484 | if err != nil { |
| 485 | t.Fatalf("Text after a log-cap refusal: %v", err) |
| 486 | } |
| 487 | if text != "" { |
| 488 | t.Errorf("expected an untouched (empty) document after a log-cap refusal, got %q", text) |
| 489 | } |
| 490 | } |
| 491 | |
| 492 | // checkABIVersion is the pure decision instantiate defers to. Fix round 1 |
| 493 | // on Task 2: the previous version of this coverage (TestABIVersionIsPinnedAtTwo |
| 494 | // in core_test.go) only ever called core.New, which never reaches this |
| 495 | // check at all — deleting the check outright still left that test passing. |
| 496 | // These cases exercise the decision directly, with no compiled wasm module |
| 497 | // standing in for "reported version". |
| 498 | func TestCheckABIVersion(t *testing.T) { |
| 499 | cases := []struct { |
| 500 | name string |
| 501 | reported uint32 |
| 502 | want uint32 |
| 503 | wantErr bool |
| 504 | }{ |
| 505 | {name: "equal", reported: 2, want: 2, wantErr: false}, |
| 506 | {name: "reported lower", reported: 1, want: 2, wantErr: true}, |
| 507 | {name: "reported higher", reported: 3, want: 2, wantErr: true}, |
| 508 | {name: "reported zero", reported: 0, want: 2, wantErr: true}, |
| 509 | } |
| 510 | for _, tc := range cases { |
| 511 | t.Run(tc.name, func(t *testing.T) { |
| 512 | err := checkABIVersion(tc.reported, tc.want) |
| 513 | if tc.wantErr && err == nil { |
| 514 | t.Fatalf("checkABIVersion(%d, %d) = nil, want an error", tc.reported, tc.want) |
| 515 | } |
| 516 | if !tc.wantErr && err != nil { |
| 517 | t.Fatalf("checkABIVersion(%d, %d) = %v, want nil", tc.reported, tc.want, err) |
| 518 | } |
| 519 | }) |
| 520 | } |
| 521 | } |
| 522 | |
| 523 | // The error text is part of the contract, not incidental: whoever reads |
| 524 | // it in production needs both numbers to tell which side is stale. |
| 525 | func TestCheckABIVersionErrorNamesBothNumbers(t *testing.T) { |
| 526 | err := checkABIVersion(5, 2) |
| 527 | if err == nil { |
| 528 | t.Fatalf("checkABIVersion(5, 2) = nil, want an error") |
| 529 | } |
| 530 | want := "core module reports ABI 5, host understands 2" |
| 531 | if err.Error() != want { |
| 532 | t.Errorf("checkABIVersion(5, 2).Error() = %q, want %q", err.Error(), want) |
| 533 | } |
| 534 | } |
| 535 | |