oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

1package 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
47import (
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.
65type rng struct{ x uint64 }
66
67func (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
76func (r *rng) nextUsize(bound int) int {
77 return int(r.nextU64() % uint64(bound))
78}
79
80func 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
94func 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
103func 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.
142func 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
151func 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.
182func 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.
276func 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
299func 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.
455func 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".
498func 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.
525func 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