// 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 under wasmi's interpreter 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 // (default one second, tunable per Runtime via WithGuestCallTimeout — see // its doc comment for why 1s and not the originally-chosen 5s), 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. // // # Rebuilding is log replay, not snapshot restore // // Spec §5.2 describes recovery as rebuilding "from its latest snapshot" — // a persisted, compacted state plus a bounded tail of subsequent updates. // As of ABI v4 the guest module can encode its whole state as one update // (docs_core_snapshot_len / docs_core_snapshot_read) and Handle.Snapshot // below returns those bytes, but a Handle's own recovery still does not // use them: every Handle keeps the updates it has itself applied // successfully, in memory, for the life of the Handle, and a discarded // instance is rebuilt by replaying that entire log from an empty // document. Nothing in here is persisted, so there is nothing in here // to restore from — §5.2's snapshot restore is the host's to perform // with what Handle.Snapshot hands it, and internal/docs does now // perform it, storing snapshots in doc_snapshots and starting each // document's replay from the newest one. This is a deliberate division, // not an oversight, and it is why the in-memory log still needs its own // bound (maxRetainedUpdates below): within this package "replay // everything" remains the only recovery available, so the log cannot be // allowed to grow without limit. 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. // // Bumped to 4 alongside core/src/lib.rs's docs_core_abi_version for the // snapshot export pair (docs_core_snapshot_len / docs_core_snapshot_read) // that encodes a document's whole state as one update — see that // function's doc comment for why an added export forces a version bump // here even though it is, strictly, backwards compatible on the wasm // side: the module and this binary ship embedded together with no // version skew to tolerate, so the bump exists to catch a stale .wasm // at load time instead of "unknown export" deep inside a request. const abiVersion = 4 // ABIVersion is abiVersion, exported for callers outside this package // (snapshot storage and its tests) that need to know which ABI contract // this host was built against. It is the same constant, not a second // number that could drift from it. const ABIVersion = abiVersion // defaultGuestCallTimeout bounds every single call into the wasm module, // unless overridden with WithGuestCallTimeout. // // This was originally 5s, chosen only from real-fixture timings (tens of // milliseconds at most). Fix-round-1 review measured the actual motivating // case — Task 5's 163-second-under-wasmi reproducer, replayed against // wazero's compiler engine at the memory-growth state that produces the // slowdown (core_internal_test.go's TestSlowUpdateIsCutOffByGuestCallTimeout) // — at ~4.34s. A 5s cap never fires for the input it exists to stop; it // would have been dead code for its own motivating case. One second keeps // roughly 1000x headroom over real fixtures (sub-millisecond) while // actually cutting off the pathological case at about a quarter of its // natural running time instead of letting it nearly finish first. const defaultGuestCallTimeout = 1 * time.Second // maxRetainedUpdates bounds how many previously-applied updates a Handle // will keep for the log-replay recovery described above. Without a bound, // a long-lived, actively-edited document's log — and so the O(n) cost of // any future rebuild, and the blast radius of a single trapped or // timed-out call forcing one — would grow without limit (fix-round-1 // finding 5). // // The real fix is snapshot support: ABI v4 now exports a state-encode // pair (docs_core_snapshot_len / docs_core_snapshot_read) that encodes a // document's current state as one update, so a rebuild restores a // compacted snapshot plus only the updates since it, matching spec §5.2 and // the doc_snapshots table spec §4.1 describes — bounded, cheap recovery // regardless of a document's total history. This package exposes that // export (Handle.Snapshot, below); persisting the result is the host's // job, and internal/docs/store.go's Store.replay and Store.ApplyUpdate // now do it, writing a snapshot every 256 updates and replaying from // the newest one instead of from the whole log. // // This cap stays regardless, as the floor under that arrangement: once // a document's log is full, Apply refuses to accept further updates at // all (ErrUpdateLogFull), rather than either growing the log without // bound or silently dropping old entries — dropping old entries would // make a later rebuild replay an incomplete history and produce // silently wrong content, which is worse than refusing. // // How reachable this cap actually is, for whoever writes the // snapshot/compaction plan: the docs app persists one doc_updates row // per accepted POST (internal/docs/store.go's Store.ApplyUpdate), and // fixtures/editor-entry.js posts at most once per its own debounce // window — 400ms at Task 6's original writing, which put this 4096 // cap within reach after roughly 27 minutes of one person typing // continuously (4096 * 400ms). Task 6 fix round 1 (F3) widened that // debounce to 2000ms as cheap headroom (4096 * 2s ≈ 136 minutes) and // made hitting the cap a reported, non-silent condition on the client // (editor-entry.js's save queue treats ErrUpdateLogFull's 400 as // permanent, same as any other 4xx) rather than a client-side fix // here — but headroom is all a wider debounce ever buys. The actual // fix for a document that keeps being edited past that point is this // comment's own snapshot support, not a bigger number here. const maxRetainedUpdates = 4096 // MaxRetainedUpdates is maxRetainedUpdates, exported for callers outside // this package (snapshot storage and its tests) that need the same cap. // It is the same constant, not a second number that could drift from it. const MaxRetainedUpdates = maxRetainedUpdates // ErrUpdateLogFull is returned by Handle.Apply once a document's retained // update log has reached maxRetainedUpdates. See that constant's doc // comment: ABI v4 exports the snapshot/compaction primitive, this package // calls it (Handle.Snapshot, Handle.VerifySnapshot, below), and // internal/docs now persists the result, so an ordinary document replays // from a snapshot plus a short tail and never comes near this limit. // // It is still reachable, and deliberately so rather than raised or // removed. A document whose snapshot writes keep failing walks its tail // back to the cap; a document that was already past it before snapshots // shipped starts there. Both end here, refusing the write, which is the // one honest answer available — the alternative, dropping old entries to // make room, would make a later rebuild replay an incomplete history and // return silently wrong content. Refusing is visible; wrong content is // not. // // The two cases do not recover alike, and only one of them heals on its // own. Every attempt the host makes tries to write a snapshot from the // replayed state before it applies anything (the snapshots design, D3), // so the document that predates snapshots gets one on the very POST it // fails, and its caller's retry gets through — one visible failure, then // healed. The document whose snapshot writes keep failing gets no such // rescue: the retry's write fails for whatever reason the last fifteen // did, and it stays here until that cause is fixed. Those failures are // logged at Error on the way past, which is the only warning anyone // gets that a document is walking towards this. var ErrUpdateLogFull = errors.New("update log exceeds the retained limit; the document needs a snapshot before it can accept further updates") // Option configures a Runtime. See WithGuestCallTimeout. type Option func(*runtimeOptions) type runtimeOptions struct { callTimeout time.Duration } // WithGuestCallTimeout overrides defaultGuestCallTimeout for one Runtime. // Exists so the cap can be tuned (for a slower/faster production host, or // for a test that wants a tight budget without waiting out the default) in // a config value instead of a recompile. func WithGuestCallTimeout(d time.Duration) Option { return func(o *runtimeOptions) { o.callTimeout = d } } // 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 // callTimeout bounds every guest call made through this Runtime and // every Handle it creates. See defaultGuestCallTimeout and // WithGuestCallTimeout. callTimeout time.Duration } func New(ctx context.Context, opts ...Option) (*Runtime, error) { o := runtimeOptions{callTimeout: defaultGuestCallTimeout} for _, opt := range opts { opt(&o) } // 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 a pathologically slow // 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, callTimeout: o.callTimeout}, 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, r.callTimeout) 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, r.callTimeout, mod.ExportedFunction("docs_core_abi_version")) if err != nil { mod.Close(ctx) return nil, fmt.Errorf("reading abi version: %w", err) } if err := checkABIVersion(uint32(version[0]), abiVersion); err != nil { mod.Close(ctx) return nil, err } return mod, nil } // checkABIVersion reports whether reported (what a module's // docs_core_abi_version export returned) matches want (abiVersion, the // contract this host understands). Pulled out of instantiate as a pure // function so the decision itself is unit-testable without a compiled // wasm module standing in for every case — the previous test here // (TestABIVersionIsPinnedAtTwo) only ever called core.New, which never // reaches this check at all, and so passed even with the check deleted // outright. // // The error text names both numbers deliberately: whoever hits this in // production needs to know which side is stale (an old host against a // rebuilt module, or vice versa) without also reading wasm build logs. func checkABIVersion(reported, want uint32) error { if reported != want { return fmt.Errorf("core module reports ABI %d, host understands %d", reported, want) } return nil } // callGuest calls fn under the given timeout. 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, timeout time.Duration, fn api.Function, params ...uint64) ([]uint64, error) { cctx, cancel := context.WithTimeout(ctx, timeout) 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) } // RecoveredError wraps the error from an Apply call whose instance // trapped or timed out but was then successfully discarded and rebuilt — // so the *document* is intact (every previously-applied update survived), // even though this specific update was not (its outcome, applied or not, // is unknown; it is never retried). Callers that only check "err != nil" // cannot otherwise tell that apart from an ordinary clean rejection (where // the instance was never touched) or from a rebuild that itself failed // (a plain joined error — see Apply — where there is no usable instance at // all). Use Recovered(err) rather than a type assertion. type RecoveredError struct{ err error } func (e *RecoveredError) Error() string { return e.err.Error() } func (e *RecoveredError) Unwrap() error { return e.err } // Recovered reports whether err came from an Apply call that trapped or // timed out but whose instance was successfully discarded and rebuilt — // see RecoveredError. func Recovered(err error) bool { var r *RecoveredError return errors.As(err, &r) } // RejectedError wraps the error from an Apply call that cleanly refused // the update (a negative return code from the guest, e.g. malformed wire // bytes yrs's decoder catches) without the instance ever being touched — // the document's existing content is completely unaffected, and the // instance is exactly as usable afterward as it was before the call. This // is the ordinary, expected way most bad input is refused; see Apply's // own doc comment. // // A caller outside this package cannot otherwise distinguish this case // from a rebuild that itself failed (Apply's plain errors.Join branch — // a fatal error, and one this package genuinely could not recover from) // or from a wholly unrelated failure in a caller's own pipeline: neither // of those is "the client's fault" the way this is, and before this type // existed, both looked identical to this from the outside — a bare, // untyped error. Use Rejected(err) rather than a type assertion, the same // pattern Recovered(err) above already establishes for the trap-and- // rebuild-succeeded case. type RejectedError struct{ err error } func (e *RejectedError) Error() string { return e.err.Error() } func (e *RejectedError) Unwrap() error { return e.err } // Rejected reports whether err came from an Apply call that cleanly // refused the update with the instance left fully usable — see // RejectedError. func Rejected(err error) bool { var r *RejectedError return errors.As(err, &r) } // 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), up to maxRetainedUpdates. 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." See the // package doc comment ("Rebuilding is log replay, not snapshot restore") // for why this is a log rather than an actual snapshot. 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, r.callTimeout, 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, timeout time.Duration, mod api.Module, guid string, clientID uint64) (uint64, error) { guidBytes := []byte(guid) ptr, err := allocIn(ctx, timeout, mod, uint64(len(guidBytes))) if err != nil { return 0, fmt.Errorf("alloc for guid: %w", err) } defer freeIn(ctx, timeout, 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, timeout, 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, timeout time.Duration, mod api.Module, length uint64) (uint64, error) { res, err := callGuest(ctx, timeout, 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, timeout time.Duration, mod api.Module, ptr, length uint64) { _, _ = callGuest(ctx, timeout, mod.ExportedFunction("docs_core_free"), ptr, length) } func (h *Handle) Close(ctx context.Context) error { _, err := callGuest(ctx, h.rt.callTimeout, 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 // callTimeout to still bound each individual call made during the rebuild // itself. func (h *Handle) rebuild(ctx context.Context) error { rebuildCtx := context.WithoutCancel(ctx) timeout := h.rt.callTimeout // 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, timeout, 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, timeout, 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, timeout time.Duration, mod api.Module, docHandle uint64, update []byte) error { ptr, err := allocIn(ctx, timeout, mod, uint64(len(update))) if err != nil { return fmt.Errorf("alloc: %w", err) } defer freeIn(ctx, timeout, 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, timeout, 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. That case comes back as a *RejectedError // (see Rejected). 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. When that rebuild succeeds, the returned error is a // *RecoveredError (see Recovered) so a caller can tell "this update was // rejected but the document is fine" apart from "the document itself is // gone" (a rebuild that fails returns a plain joined error instead — the // one shape this method returns that is neither *RejectedError nor // *RecoveredError, and the one a caller must not mistake for either: the // document itself may now be unusable, not merely this one update). // // Once the document's retained update log is full (maxRetainedUpdates), // Apply refuses any further update — including one that would otherwise // apply cleanly — with ErrUpdateLogFull, rather than growing the log // without bound. See maxRetainedUpdates's doc comment. func (h *Handle) Apply(ctx context.Context, update []byte) error { if len(h.applied) >= maxRetainedUpdates { return ErrUpdateLogFull } err := applyOn(ctx, h.rt.callTimeout, 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 &RecoveredError{err} } return &RejectedError{err} } // Text returns the document's current text content. If the read itself // traps or times out, the instance is discarded and rebuilt exactly as in // Apply, and the read is retried once against the freshly rebuilt // instance — the content is available at that point, and returning an // empty string plus an error instead of it would tell the caller the data // is gone when it is not. func (h *Handle) Text(ctx context.Context) (string, error) { return h.read(ctx, "text") } // HTML returns the document's current content rendered as HTML (spec // §6.2: the read view is server-rendered, no script required). func (h *Handle) HTML(ctx context.Context) (string, error) { return h.read(ctx, "html") } // JSON returns the document's structure as ProseMirror-shaped JSON — // what the export paths (internal/export, internal/pdf) read, since // Markdown and PDF both need to know where a list ends and which run is // bold, and neither can get that from HTML without a parser. See // core/src/fragment.rs's fragment_json. // // The bytes are always a complete JSON document for a live handle, // never "" — an empty document is {"type":"doc","content":[]} — so a // caller may unmarshal the result without a special case for empty. func (h *Handle) JSON(ctx context.Context) (string, error) { return h.read(ctx, "json") } // Snapshot returns the document's entire state as one Yjs update — what // doc_snapshots stores so a later replay can start from it instead of // from the whole log (the snapshots design, D1). // // Bytes rather than a string, but it rides the same len/read machinery // as Text/HTML/JSON, including their trap-and-retry: a Go string is a // byte sequence and nothing here interprets it, so "snapshot" is simply // a fourth renderer name to readOn. func (h *Handle) Snapshot(ctx context.Context) ([]byte, error) { s, err := h.read(ctx, "snapshot") if err != nil { return nil, err } return []byte(s), nil } // verifySnapshotOn allocates candidate into mod's linear memory, calls // docs_core_verify_snapshot against docHandle, frees the buffer, and // returns the raw i32 return code. Structured exactly like applyOn — // docs_core_verify_snapshot is the only other export besides // docs_core_apply that takes a caller-supplied byte buffer into guest // memory, so the alloc/write/call/free dance and its error handling are // copied from there rather than reinvented. func verifySnapshotOn(ctx context.Context, timeout time.Duration, mod api.Module, docHandle uint64, candidate []byte) (int32, error) { ptr, err := allocIn(ctx, timeout, mod, uint64(len(candidate))) if err != nil { return 0, fmt.Errorf("alloc: %w", err) } defer freeIn(ctx, timeout, mod, ptr, uint64(len(candidate))) if !mod.Memory().Write(uint32(ptr), candidate) { return 0, fmt.Errorf("writing %d bytes at %d: out of range", len(candidate), ptr) } rc, err := callGuest(ctx, timeout, mod.ExportedFunction("docs_core_verify_snapshot"), docHandle, ptr, uint64(len(candidate))) if err != nil { return 0, fmt.Errorf("verify_snapshot trapped or timed out: %w", err) } return int32(rc[0]), nil } // VerifySnapshot asks the core whether candidate, replayed into an empty // document, is this document — state vector, delete set and structural // render (the snapshots design, D5). It is a smoke test rather than a // proof, and it cannot detect a snapshot taken from a stale state; see // core/src/snapshot.rs for both limits. // // docs_core_verify_snapshot's four outcomes map as: 0 (the candidate is // this document) -> true, nil; 1 (it is not) -> false, nil; ERR_DECODE // (candidate is not a decodable update at all) -> false, nil — bad bytes // are a "no" answer, not a failure of this call; anything else (a trap, // a deadline exceeded, ERR_NO_DOC, ERR_INVALID_LENGTH, or an // unrecognised code) -> false, error. // // A trap or timeout is handled exactly as in Apply: the instance is // discarded and rebuilt from every update applied here so far (the // document itself survives), and the returned error is a *RecoveredError // so a caller can tell that apart from a rebuild that itself failed. // Unlike Apply, there is no update to omit from replay — this call never // mutates h's document at all, only a throwaway one inside the guest. func (h *Handle) VerifySnapshot(ctx context.Context, candidate []byte) (bool, error) { const errDecode = -2 code, err := verifySnapshotOn(ctx, h.rt.callTimeout, h.mod, h.handle, candidate) if err != nil { if isFatal(err) { if rebuildErr := h.rebuild(ctx); rebuildErr != nil { return false, errors.Join(err, rebuildErr) } return false, &RecoveredError{err} } return false, err } switch code { case 0: return true, nil case 1: return false, nil case errDecode: return false, nil default: return false, fmt.Errorf("verify_snapshot failed: code %d", code) } } // read is the shared body of Text, HTML, JSON and Snapshot: call the named // renderer's len/read pair, and if the call itself traps or times out, // discard the instance, rebuild it exactly as Apply does, and retry the // read once against the fresh one. Retrying rather than reporting an // error is deliberate — the content is available at that point, and // returning an empty string plus an error would tell the caller the // data is gone when it is not. // // One function rather than three near-identical ones. It was three // (textOn/htmlOn, and jsonOn was about to be the third): the same forty // lines with two identifiers substituted, which is three places for a // fix like fix-round-1's finding 3 (read the written count, not the // buffer size) to be applied to two of. func (h *Handle) read(ctx context.Context, renderer string) (string, error) { s, err := readOn(ctx, h.rt.callTimeout, h.mod, h.handle, renderer) if err == nil { return s, nil } if !isFatal(err) { return "", err } if rebuildErr := h.rebuild(ctx); rebuildErr != nil { return "", errors.Join(err, rebuildErr) } s, err = readOn(ctx, h.rt.callTimeout, h.mod, h.handle, renderer) if err != nil { return "", fmt.Errorf("reading %s from the rebuilt instance: %w", renderer, err) } return s, nil } // readOn performs one renderer's len-then-read dance against // docs_core__len and docs_core__read. renderer is // always one of this package's own four literals ("text", "html", // "json", "snapshot"), never anything derived from a request — the // export names it builds are a closed set, and a name that does not // resolve to an exported function surfaces below as a nil api.Function. func readOn(ctx context.Context, timeout time.Duration, mod api.Module, docHandle uint64, renderer string) (string, error) { lenFn := mod.ExportedFunction("docs_core_" + renderer + "_len") readFn := mod.ExportedFunction("docs_core_" + renderer + "_read") if lenFn == nil || readFn == nil { // Unreachable while abiVersion matches — the version check at // load is what proves these exports exist — but a nil // api.Function panics inside callGuest rather than erroring, so // this turns a future ABI mistake into a returned error instead // of a panic in a request handler. return "", fmt.Errorf("module exports no %q renderer", renderer) } lenRes, err := callGuest(ctx, timeout, lenFn, docHandle) if err != nil { return "", fmt.Errorf("%s_len trapped or timed out: %w", renderer, err) } // The len functions return 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("%s_len failed: code %d", renderer, code) } n := lenRes[0] if n == 0 { return "", nil } ptr, err := allocIn(ctx, timeout, mod, n) if err != nil { return "", fmt.Errorf("alloc: %w", err) } defer freeIn(ctx, timeout, mod, ptr, n) rc, err := callGuest(ctx, timeout, readFn, docHandle, ptr, n) if err != nil { return "", fmt.Errorf("%s_read trapped or timed out: %w", renderer, err) } written := int32(rc[0]) if written < 0 { return "", fmt.Errorf("%s_read failed: code %d", renderer, written) } // The read functions return s.len(), guaranteed only <= out_len // (core/src/abi.rs — the buffer sized by the len call is an upper // bound, not a promise the two calls agree exactly). Reading n bytes // instead of the written count would, if they ever diverged, append // uninitialised allocator bytes past the real content to the // returned string (fix-round-1 finding 3) — read exactly what the // read call says it wrote. buf, ok := mod.Memory().Read(uint32(ptr), uint32(written)) if !ok { return "", fmt.Errorf("reading %d bytes at %d: out of range", written, ptr) } return string(buf), nil }