| 1 | // Package core hosts the Rust document core as a WebAssembly module. |
| 2 | // |
| 3 | // The module is plain wasm32 with a C ABI and no wasm-bindgen, because |
| 4 | // wazero provides no JS glue. Everything crosses the boundary as integers |
| 5 | // and offsets into the module's linear memory. |
| 6 | // |
| 7 | // # Two failure modes the host must contain, not merely survive |
| 8 | // |
| 9 | // yrs decodes wire bytes with std::str::from_utf8_unchecked in places |
| 10 | // (core/src/abi.rs's module doc comment), so a sufficiently corrupted |
| 11 | // update can reach real undefined behaviour and trap the module. A wasm |
| 12 | // trap does not unwind — the module's linear memory is left exactly as it |
| 13 | // was at the moment of the trap, which for a thread-local RefCell borrow |
| 14 | // guard means "permanently borrowed". The instance is not recoverable; |
| 15 | // every Handle here is backed by exactly one module instance per document |
| 16 | // (spec §5.2), and a trap discards that instance rather than reusing it. |
| 17 | // |
| 18 | // Separately, a malformed update need not trap at all: Task 5's fuzzing |
| 19 | // found a 36-byte corrupted update that ran docs_core_apply for 163 real |
| 20 | // CPU seconds under wasmi's interpreter before returning an ordinary, |
| 21 | // correct answer — not a hang, but on a server accepting updates from |
| 22 | // anyone with a share link it is a denial-of-service vector no correctness |
| 23 | // test can see. Every guest call here runs under a context.WithTimeout |
| 24 | // (default one second, tunable per Runtime via WithGuestCallTimeout — see |
| 25 | // its doc comment for why 1s and not the originally-chosen 5s), and the |
| 26 | // Runtime is built with WithCloseOnContextDone(true) so wazero actually |
| 27 | // terminates a call that overruns its budget rather than merely making the |
| 28 | // caller stop waiting. A deadline exceeded is treated exactly like a trap: |
| 29 | // the instance is discarded and a fresh one is rebuilt. |
| 30 | // |
| 31 | // # Rebuilding is log replay, not snapshot restore |
| 32 | // |
| 33 | // Spec §5.2 describes recovery as rebuilding "from its latest snapshot" — |
| 34 | // a persisted, compacted state plus a bounded tail of subsequent updates. |
| 35 | // As of ABI v4 the guest module can encode its whole state as one update |
| 36 | // (docs_core_snapshot_len / docs_core_snapshot_read) and Handle.Snapshot |
| 37 | // below returns those bytes, but a Handle's own recovery still does not |
| 38 | // use them: every Handle keeps the updates it has itself applied |
| 39 | // successfully, in memory, for the life of the Handle, and a discarded |
| 40 | // instance is rebuilt by replaying that entire log from an empty |
| 41 | // document. Nothing in here is persisted, so there is nothing in here |
| 42 | // to restore from — §5.2's snapshot restore is the host's to perform |
| 43 | // with what Handle.Snapshot hands it, and internal/docs does now |
| 44 | // perform it, storing snapshots in doc_snapshots and starting each |
| 45 | // document's replay from the newest one. This is a deliberate division, |
| 46 | // not an oversight, and it is why the in-memory log still needs its own |
| 47 | // bound (maxRetainedUpdates below): within this package "replay |
| 48 | // everything" remains the only recovery available, so the log cannot be |
| 49 | // allowed to grow without limit. |
| 50 | package core |
| 51 | |
| 52 | import ( |
| 53 | "context" |
| 54 | _ "embed" |
| 55 | "errors" |
| 56 | "fmt" |
| 57 | "time" |
| 58 | |
| 59 | "github.com/tetratelabs/wazero" |
| 60 | "github.com/tetratelabs/wazero/api" |
| 61 | ) |
| 62 | |
| 63 | //go:embed testdata/docs_core.wasm |
| 64 | var moduleWASM []byte |
| 65 | |
| 66 | // abiVersion is the contract this host understands. A module reporting |
| 67 | // anything else is refused at load, so a stale .wasm (see |
| 68 | // scripts/build-wasm.sh and the copy step in Task 6's brief) fails loudly |
| 69 | // rather than behaving strangely. |
| 70 | // |
| 71 | // Bumped to 4 alongside core/src/lib.rs's docs_core_abi_version for the |
| 72 | // snapshot export pair (docs_core_snapshot_len / docs_core_snapshot_read) |
| 73 | // that encodes a document's whole state as one update — see that |
| 74 | // function's doc comment for why an added export forces a version bump |
| 75 | // here even though it is, strictly, backwards compatible on the wasm |
| 76 | // side: the module and this binary ship embedded together with no |
| 77 | // version skew to tolerate, so the bump exists to catch a stale .wasm |
| 78 | // at load time instead of "unknown export" deep inside a request. |
| 79 | const abiVersion = 4 |
| 80 | |
| 81 | // ABIVersion is abiVersion, exported for callers outside this package |
| 82 | // (snapshot storage and its tests) that need to know which ABI contract |
| 83 | // this host was built against. It is the same constant, not a second |
| 84 | // number that could drift from it. |
| 85 | const ABIVersion = abiVersion |
| 86 | |
| 87 | // defaultGuestCallTimeout bounds every single call into the wasm module, |
| 88 | // unless overridden with WithGuestCallTimeout. |
| 89 | // |
| 90 | // This was originally 5s, chosen only from real-fixture timings (tens of |
| 91 | // milliseconds at most). Fix-round-1 review measured the actual motivating |
| 92 | // case — Task 5's 163-second-under-wasmi reproducer, replayed against |
| 93 | // wazero's compiler engine at the memory-growth state that produces the |
| 94 | // slowdown (core_internal_test.go's TestSlowUpdateIsCutOffByGuestCallTimeout) |
| 95 | // — at ~4.34s. A 5s cap never fires for the input it exists to stop; it |
| 96 | // would have been dead code for its own motivating case. One second keeps |
| 97 | // roughly 1000x headroom over real fixtures (sub-millisecond) while |
| 98 | // actually cutting off the pathological case at about a quarter of its |
| 99 | // natural running time instead of letting it nearly finish first. |
| 100 | const defaultGuestCallTimeout = 1 * time.Second |
| 101 | |
| 102 | // maxRetainedUpdates bounds how many previously-applied updates a Handle |
| 103 | // will keep for the log-replay recovery described above. Without a bound, |
| 104 | // a long-lived, actively-edited document's log — and so the O(n) cost of |
| 105 | // any future rebuild, and the blast radius of a single trapped or |
| 106 | // timed-out call forcing one — would grow without limit (fix-round-1 |
| 107 | // finding 5). |
| 108 | // |
| 109 | // The real fix is snapshot support: ABI v4 now exports a state-encode |
| 110 | // pair (docs_core_snapshot_len / docs_core_snapshot_read) that encodes a |
| 111 | // document's current state as one update, so a rebuild restores a |
| 112 | // compacted snapshot plus only the updates since it, matching spec §5.2 and |
| 113 | // the doc_snapshots table spec §4.1 describes — bounded, cheap recovery |
| 114 | // regardless of a document's total history. This package exposes that |
| 115 | // export (Handle.Snapshot, below); persisting the result is the host's |
| 116 | // job, and internal/docs/store.go's Store.replay and Store.ApplyUpdate |
| 117 | // now do it, writing a snapshot every 256 updates and replaying from |
| 118 | // the newest one instead of from the whole log. |
| 119 | // |
| 120 | // This cap stays regardless, as the floor under that arrangement: once |
| 121 | // a document's log is full, Apply refuses to accept further updates at |
| 122 | // all (ErrUpdateLogFull), rather than either growing the log without |
| 123 | // bound or silently dropping old entries — dropping old entries would |
| 124 | // make a later rebuild replay an incomplete history and produce |
| 125 | // silently wrong content, which is worse than refusing. |
| 126 | // |
| 127 | // How reachable this cap actually is, for whoever writes the |
| 128 | // snapshot/compaction plan: the docs app persists one doc_updates row |
| 129 | // per accepted POST (internal/docs/store.go's Store.ApplyUpdate), and |
| 130 | // fixtures/editor-entry.js posts at most once per its own debounce |
| 131 | // window — 400ms at Task 6's original writing, which put this 4096 |
| 132 | // cap within reach after roughly 27 minutes of one person typing |
| 133 | // continuously (4096 * 400ms). Task 6 fix round 1 (F3) widened that |
| 134 | // debounce to 2000ms as cheap headroom (4096 * 2s ≈ 136 minutes) and |
| 135 | // made hitting the cap a reported, non-silent condition on the client |
| 136 | // (editor-entry.js's save queue treats ErrUpdateLogFull's 400 as |
| 137 | // permanent, same as any other 4xx) rather than a client-side fix |
| 138 | // here — but headroom is all a wider debounce ever buys. The actual |
| 139 | // fix for a document that keeps being edited past that point is this |
| 140 | // comment's own snapshot support, not a bigger number here. |
| 141 | const maxRetainedUpdates = 4096 |
| 142 | |
| 143 | // MaxRetainedUpdates is maxRetainedUpdates, exported for callers outside |
| 144 | // this package (snapshot storage and its tests) that need the same cap. |
| 145 | // It is the same constant, not a second number that could drift from it. |
| 146 | const MaxRetainedUpdates = maxRetainedUpdates |
| 147 | |
| 148 | // ErrUpdateLogFull is returned by Handle.Apply once a document's retained |
| 149 | // update log has reached maxRetainedUpdates. See that constant's doc |
| 150 | // comment: ABI v4 exports the snapshot/compaction primitive, this package |
| 151 | // calls it (Handle.Snapshot, Handle.VerifySnapshot, below), and |
| 152 | // internal/docs now persists the result, so an ordinary document replays |
| 153 | // from a snapshot plus a short tail and never comes near this limit. |
| 154 | // |
| 155 | // It is still reachable, and deliberately so rather than raised or |
| 156 | // removed. A document whose snapshot writes keep failing walks its tail |
| 157 | // back to the cap; a document that was already past it before snapshots |
| 158 | // shipped starts there. Both end here, refusing the write, which is the |
| 159 | // one honest answer available — the alternative, dropping old entries to |
| 160 | // make room, would make a later rebuild replay an incomplete history and |
| 161 | // return silently wrong content. Refusing is visible; wrong content is |
| 162 | // not. |
| 163 | // |
| 164 | // The two cases do not recover alike, and only one of them heals on its |
| 165 | // own. Every attempt the host makes tries to write a snapshot from the |
| 166 | // replayed state before it applies anything (the snapshots design, D3), |
| 167 | // so the document that predates snapshots gets one on the very POST it |
| 168 | // fails, and its caller's retry gets through — one visible failure, then |
| 169 | // healed. The document whose snapshot writes keep failing gets no such |
| 170 | // rescue: the retry's write fails for whatever reason the last fifteen |
| 171 | // did, and it stays here until that cause is fixed. Those failures are |
| 172 | // logged at Error on the way past, which is the only warning anyone |
| 173 | // gets that a document is walking towards this. |
| 174 | var ErrUpdateLogFull = errors.New("update log exceeds the retained limit; the document needs a snapshot before it can accept further updates") |
| 175 | |
| 176 | // Option configures a Runtime. See WithGuestCallTimeout. |
| 177 | type Option func(*runtimeOptions) |
| 178 | |
| 179 | type runtimeOptions struct { |
| 180 | callTimeout time.Duration |
| 181 | } |
| 182 | |
| 183 | // WithGuestCallTimeout overrides defaultGuestCallTimeout for one Runtime. |
| 184 | // Exists so the cap can be tuned (for a slower/faster production host, or |
| 185 | // for a test that wants a tight budget without waiting out the default) in |
| 186 | // a config value instead of a recompile. |
| 187 | func WithGuestCallTimeout(d time.Duration) Option { |
| 188 | return func(o *runtimeOptions) { o.callTimeout = d } |
| 189 | } |
| 190 | |
| 191 | // Runtime owns the compiled module. Compiling is expensive and |
| 192 | // instantiating is not, so one Runtime is shared and each document gets its |
| 193 | // own instance (spec §5.2: instances are not safe for concurrent use). |
| 194 | type Runtime struct { |
| 195 | rt wazero.Runtime |
| 196 | compiled wazero.CompiledModule |
| 197 | |
| 198 | // callTimeout bounds every guest call made through this Runtime and |
| 199 | // every Handle it creates. See defaultGuestCallTimeout and |
| 200 | // WithGuestCallTimeout. |
| 201 | callTimeout time.Duration |
| 202 | } |
| 203 | |
| 204 | func New(ctx context.Context, opts ...Option) (*Runtime, error) { |
| 205 | o := runtimeOptions{callTimeout: defaultGuestCallTimeout} |
| 206 | for _, opt := range opts { |
| 207 | opt(&o) |
| 208 | } |
| 209 | |
| 210 | // WithCloseOnContextDone(true): without this, a context deadline or |
| 211 | // cancellation on a guest call only makes the *caller* stop waiting — |
| 212 | // the underlying goroutine executing wasm keeps running to completion. |
| 213 | // With it, wazero itself terminates the call and closes the module |
| 214 | // when the context is done, which is what turns a pathologically slow |
| 215 | // input into a bounded failure instead of a resource leak. |
| 216 | cfg := wazero.NewRuntimeConfig().WithCloseOnContextDone(true) |
| 217 | rt := wazero.NewRuntimeWithConfig(ctx, cfg) |
| 218 | compiled, err := rt.CompileModule(ctx, moduleWASM) |
| 219 | if err != nil { |
| 220 | rt.Close(ctx) |
| 221 | return nil, fmt.Errorf("compiling core module: %w", err) |
| 222 | } |
| 223 | return &Runtime{rt: rt, compiled: compiled, callTimeout: o.callTimeout}, nil |
| 224 | } |
| 225 | |
| 226 | func (r *Runtime) Close(ctx context.Context) error { return r.rt.Close(ctx) } |
| 227 | |
| 228 | // instantiate creates a fresh module instance and checks its reported ABI |
| 229 | // version. Used both for a brand-new document and to rebuild after a |
| 230 | // discarded (trapped or timed-out) instance. |
| 231 | func (r *Runtime) instantiate(ctx context.Context) (api.Module, error) { |
| 232 | cctx, cancel := context.WithTimeout(ctx, r.callTimeout) |
| 233 | defer cancel() |
| 234 | // Empty name: wazero treats an anonymous module (ModuleName == "") as |
| 235 | // exempt from its store's name-uniqueness check, so instantiating many |
| 236 | // of these concurrently — one per document, plus rebuilds — never |
| 237 | // collides (internal/wasm/store_module_list.go's registerModule). |
| 238 | mod, err := r.rt.InstantiateModule(cctx, r.compiled, wazero.NewModuleConfig().WithName("")) |
| 239 | if err != nil { |
| 240 | return nil, fmt.Errorf("instantiating core module: %w", err) |
| 241 | } |
| 242 | |
| 243 | version, err := callGuest(ctx, r.callTimeout, mod.ExportedFunction("docs_core_abi_version")) |
| 244 | if err != nil { |
| 245 | mod.Close(ctx) |
| 246 | return nil, fmt.Errorf("reading abi version: %w", err) |
| 247 | } |
| 248 | if err := checkABIVersion(uint32(version[0]), abiVersion); err != nil { |
| 249 | mod.Close(ctx) |
| 250 | return nil, err |
| 251 | } |
| 252 | return mod, nil |
| 253 | } |
| 254 | |
| 255 | // checkABIVersion reports whether reported (what a module's |
| 256 | // docs_core_abi_version export returned) matches want (abiVersion, the |
| 257 | // contract this host understands). Pulled out of instantiate as a pure |
| 258 | // function so the decision itself is unit-testable without a compiled |
| 259 | // wasm module standing in for every case — the previous test here |
| 260 | // (TestABIVersionIsPinnedAtTwo) only ever called core.New, which never |
| 261 | // reaches this check at all, and so passed even with the check deleted |
| 262 | // outright. |
| 263 | // |
| 264 | // The error text names both numbers deliberately: whoever hits this in |
| 265 | // production needs to know which side is stale (an old host against a |
| 266 | // rebuilt module, or vice versa) without also reading wasm build logs. |
| 267 | func checkABIVersion(reported, want uint32) error { |
| 268 | if reported != want { |
| 269 | return fmt.Errorf("core module reports ABI %d, host understands %d", reported, want) |
| 270 | } |
| 271 | return nil |
| 272 | } |
| 273 | |
| 274 | // callGuest calls fn under the given timeout. A non-nil error here means |
| 275 | // the call trapped, ran past its deadline (in which case wazero has |
| 276 | // already closed the module — WithCloseOnContextDone(true)), or the |
| 277 | // module was otherwise closed out from under it: in every case the |
| 278 | // instance must be treated as poisoned, never merely retried. The error is |
| 279 | // wrapped in trapFault so callers further up (Apply, Text) can tell it |
| 280 | // apart from an ordinary clean rejection (a negative return code from the |
| 281 | // guest), which does not poison anything — see isFatal. |
| 282 | func callGuest(ctx context.Context, timeout time.Duration, fn api.Function, params ...uint64) ([]uint64, error) { |
| 283 | cctx, cancel := context.WithTimeout(ctx, timeout) |
| 284 | defer cancel() |
| 285 | res, err := fn.Call(cctx, params...) |
| 286 | if err != nil { |
| 287 | return nil, &trapFault{err} |
| 288 | } |
| 289 | return res, nil |
| 290 | } |
| 291 | |
| 292 | // trapFault marks an error as meaning the module instance itself is no |
| 293 | // longer trustworthy: the wasm call that produced it did not return |
| 294 | // normally at all (a trap, a deadline exceeded, or the module having |
| 295 | // already been closed), as opposed to returning normally with a negative |
| 296 | // application error code. Every error that originates from a wazero |
| 297 | // api.Function.Call failing carries this; every error this package |
| 298 | // constructs itself from a clean negative return code does not, so |
| 299 | // isFatal (via errors.As, walking any fmt.Errorf("...: %w", err) wrapping) |
| 300 | // tells the two apart. |
| 301 | type trapFault struct{ err error } |
| 302 | |
| 303 | func (t *trapFault) Error() string { return t.err.Error() } |
| 304 | func (t *trapFault) Unwrap() error { return t.err } |
| 305 | |
| 306 | // isFatal reports whether err means the module instance must be discarded |
| 307 | // (a trap or a deadline exceeded reaching all the way from callGuest) as |
| 308 | // opposed to an ordinary clean rejection, which leaves the instance fully |
| 309 | // usable (TestGarbageUpdateReturnsErrorNotTrap). |
| 310 | func isFatal(err error) bool { |
| 311 | var t *trapFault |
| 312 | return errors.As(err, &t) |
| 313 | } |
| 314 | |
| 315 | // RecoveredError wraps the error from an Apply call whose instance |
| 316 | // trapped or timed out but was then successfully discarded and rebuilt — |
| 317 | // so the *document* is intact (every previously-applied update survived), |
| 318 | // even though this specific update was not (its outcome, applied or not, |
| 319 | // is unknown; it is never retried). Callers that only check "err != nil" |
| 320 | // cannot otherwise tell that apart from an ordinary clean rejection (where |
| 321 | // the instance was never touched) or from a rebuild that itself failed |
| 322 | // (a plain joined error — see Apply — where there is no usable instance at |
| 323 | // all). Use Recovered(err) rather than a type assertion. |
| 324 | type RecoveredError struct{ err error } |
| 325 | |
| 326 | func (e *RecoveredError) Error() string { return e.err.Error() } |
| 327 | func (e *RecoveredError) Unwrap() error { return e.err } |
| 328 | |
| 329 | // Recovered reports whether err came from an Apply call that trapped or |
| 330 | // timed out but whose instance was successfully discarded and rebuilt — |
| 331 | // see RecoveredError. |
| 332 | func Recovered(err error) bool { |
| 333 | var r *RecoveredError |
| 334 | return errors.As(err, &r) |
| 335 | } |
| 336 | |
| 337 | // RejectedError wraps the error from an Apply call that cleanly refused |
| 338 | // the update (a negative return code from the guest, e.g. malformed wire |
| 339 | // bytes yrs's decoder catches) without the instance ever being touched — |
| 340 | // the document's existing content is completely unaffected, and the |
| 341 | // instance is exactly as usable afterward as it was before the call. This |
| 342 | // is the ordinary, expected way most bad input is refused; see Apply's |
| 343 | // own doc comment. |
| 344 | // |
| 345 | // A caller outside this package cannot otherwise distinguish this case |
| 346 | // from a rebuild that itself failed (Apply's plain errors.Join branch — |
| 347 | // a fatal error, and one this package genuinely could not recover from) |
| 348 | // or from a wholly unrelated failure in a caller's own pipeline: neither |
| 349 | // of those is "the client's fault" the way this is, and before this type |
| 350 | // existed, both looked identical to this from the outside — a bare, |
| 351 | // untyped error. Use Rejected(err) rather than a type assertion, the same |
| 352 | // pattern Recovered(err) above already establishes for the trap-and- |
| 353 | // rebuild-succeeded case. |
| 354 | type RejectedError struct{ err error } |
| 355 | |
| 356 | func (e *RejectedError) Error() string { return e.err.Error() } |
| 357 | func (e *RejectedError) Unwrap() error { return e.err } |
| 358 | |
| 359 | // Rejected reports whether err came from an Apply call that cleanly |
| 360 | // refused the update with the instance left fully usable — see |
| 361 | // RejectedError. |
| 362 | func Rejected(err error) bool { |
| 363 | var r *RejectedError |
| 364 | return errors.As(err, &r) |
| 365 | } |
| 366 | |
| 367 | // Handle is one document, backed by its own module instance. Not safe for |
| 368 | // concurrent use; callers serialise access per document. |
| 369 | // |
| 370 | // applied records, in order, every update byte slice this handle has |
| 371 | // itself applied successfully (rc == 0), up to maxRetainedUpdates. It |
| 372 | // exists solely so a trapped or timed-out instance can be discarded and a |
| 373 | // fresh one rebuilt by replaying only the updates already known to be good |
| 374 | // — the update that triggered the discard is never replayed, matching |
| 375 | // §5.2's "the request fails cleanly; the document does not." See the |
| 376 | // package doc comment ("Rebuilding is log replay, not snapshot restore") |
| 377 | // for why this is a log rather than an actual snapshot. |
| 378 | type Handle struct { |
| 379 | rt *Runtime |
| 380 | guid string |
| 381 | clientID uint64 |
| 382 | |
| 383 | mod api.Module |
| 384 | handle uint64 |
| 385 | |
| 386 | applied [][]byte |
| 387 | } |
| 388 | |
| 389 | // NewDoc creates a document. The GUID and client id are supplied by the |
| 390 | // caller and never drawn from yrs: on wasm the CRDT library's entropy is a |
| 391 | // constant seed, so a value it generates is identical in every process, and |
| 392 | // a repeated client id or GUID corrupts the document (Global Constraints). |
| 393 | func (r *Runtime) NewDoc(ctx context.Context, guid string, clientID uint64) (*Handle, error) { |
| 394 | mod, err := r.instantiate(ctx) |
| 395 | if err != nil { |
| 396 | return nil, err |
| 397 | } |
| 398 | |
| 399 | handle := &Handle{rt: r, guid: guid, clientID: clientID, mod: mod} |
| 400 | docHandle, err := createDoc(ctx, r.callTimeout, mod, guid, clientID) |
| 401 | if err != nil { |
| 402 | mod.Close(ctx) |
| 403 | return nil, err |
| 404 | } |
| 405 | handle.handle = docHandle |
| 406 | return handle, nil |
| 407 | } |
| 408 | |
| 409 | // createDoc allocates the guid in mod's linear memory, calls |
| 410 | // docs_core_doc_new, and frees the buffer. Shared between NewDoc and a |
| 411 | // post-trap rebuild. |
| 412 | func createDoc(ctx context.Context, timeout time.Duration, mod api.Module, guid string, clientID uint64) (uint64, error) { |
| 413 | guidBytes := []byte(guid) |
| 414 | ptr, err := allocIn(ctx, timeout, mod, uint64(len(guidBytes))) |
| 415 | if err != nil { |
| 416 | return 0, fmt.Errorf("alloc for guid: %w", err) |
| 417 | } |
| 418 | defer freeIn(ctx, timeout, mod, ptr, uint64(len(guidBytes))) |
| 419 | |
| 420 | if !mod.Memory().Write(uint32(ptr), guidBytes) { |
| 421 | return 0, fmt.Errorf("writing guid at %d: out of range", ptr) |
| 422 | } |
| 423 | |
| 424 | res, err := callGuest(ctx, timeout, mod.ExportedFunction("docs_core_doc_new"), ptr, uint64(len(guidBytes)), clientID) |
| 425 | if err != nil { |
| 426 | return 0, fmt.Errorf("doc_new: %w", err) |
| 427 | } |
| 428 | if res[0] == 0 { |
| 429 | return 0, fmt.Errorf("doc_new returned a null handle") |
| 430 | } |
| 431 | return res[0], nil |
| 432 | } |
| 433 | |
| 434 | // allocIn calls docs_core_alloc and checks the 0-on-failure sentinel |
| 435 | // (including any length over isize::MAX — core/src/abi.rs's layout_for). |
| 436 | func allocIn(ctx context.Context, timeout time.Duration, mod api.Module, length uint64) (uint64, error) { |
| 437 | res, err := callGuest(ctx, timeout, mod.ExportedFunction("docs_core_alloc"), length) |
| 438 | if err != nil { |
| 439 | return 0, fmt.Errorf("alloc trapped or timed out: %w", err) |
| 440 | } |
| 441 | if res[0] == 0 { |
| 442 | return 0, fmt.Errorf("alloc(%d) failed", length) |
| 443 | } |
| 444 | return res[0], nil |
| 445 | } |
| 446 | |
| 447 | // freeIn best-efforts a free. A failure here (trap/timeout) is folded into |
| 448 | // "the instance is now poisoned", which the caller's own callGuest error |
| 449 | // (the one that actually matters) will already have triggered, so this |
| 450 | // deliberately does not itself return an error to plumb through defers. |
| 451 | func freeIn(ctx context.Context, timeout time.Duration, mod api.Module, ptr, length uint64) { |
| 452 | _, _ = callGuest(ctx, timeout, mod.ExportedFunction("docs_core_free"), ptr, length) |
| 453 | } |
| 454 | |
| 455 | func (h *Handle) Close(ctx context.Context) error { |
| 456 | _, err := callGuest(ctx, h.rt.callTimeout, h.mod.ExportedFunction("docs_core_doc_free"), h.handle) |
| 457 | if closeErr := h.mod.Close(ctx); err == nil { |
| 458 | err = closeErr |
| 459 | } |
| 460 | return err |
| 461 | } |
| 462 | |
| 463 | // rebuild discards h's current (poisoned) instance and replaces it with a |
| 464 | // fresh one for the same document (guid, clientID), replaying every update |
| 465 | // previously applied successfully. The update that triggered the discard |
| 466 | // is not replayed — it is not in h.applied, since that log is only |
| 467 | // appended to on a clean rc == 0. |
| 468 | // |
| 469 | // It deliberately does not run under ctx as given: ctx is the same context |
| 470 | // whose deadline may be exactly what triggered this rebuild (a timed-out |
| 471 | // Apply calls rebuild with its own ctx). Rebuilding under an |
| 472 | // already-expired deadline would make recovery fail precisely in the case |
| 473 | // it exists for, so this strips ctx's cancellation/deadline |
| 474 | // (context.WithoutCancel) while keeping any values on it, and relies on |
| 475 | // callTimeout to still bound each individual call made during the rebuild |
| 476 | // itself. |
| 477 | func (h *Handle) rebuild(ctx context.Context) error { |
| 478 | rebuildCtx := context.WithoutCancel(ctx) |
| 479 | timeout := h.rt.callTimeout |
| 480 | |
| 481 | // Best-effort: the old instance may already be closed (a timeout under |
| 482 | // WithCloseOnContextDone(true) closes it as part of terminating the |
| 483 | // call), so an error here is expected and not itself a failure. |
| 484 | _ = h.mod.Close(rebuildCtx) |
| 485 | |
| 486 | mod, err := h.rt.instantiate(rebuildCtx) |
| 487 | if err != nil { |
| 488 | return fmt.Errorf("rebuilding instance: %w", err) |
| 489 | } |
| 490 | docHandle, err := createDoc(rebuildCtx, timeout, mod, h.guid, h.clientID) |
| 491 | if err != nil { |
| 492 | mod.Close(rebuildCtx) |
| 493 | return fmt.Errorf("rebuilding instance: %w", err) |
| 494 | } |
| 495 | for i, update := range h.applied { |
| 496 | if err := applyOn(rebuildCtx, timeout, mod, docHandle, update); err != nil { |
| 497 | mod.Close(rebuildCtx) |
| 498 | return fmt.Errorf("rebuilding instance: replaying update %d/%d, previously applied cleanly: %w", |
| 499 | i+1, len(h.applied), err) |
| 500 | } |
| 501 | } |
| 502 | h.mod = mod |
| 503 | h.handle = docHandle |
| 504 | return nil |
| 505 | } |
| 506 | |
| 507 | // applyOn runs docs_core_apply against a specific (mod, docHandle) pair — |
| 508 | // factored out so both Handle.Apply and rebuild's replay share the same |
| 509 | // alloc/write/call/free/error-code logic. |
| 510 | func applyOn(ctx context.Context, timeout time.Duration, mod api.Module, docHandle uint64, update []byte) error { |
| 511 | ptr, err := allocIn(ctx, timeout, mod, uint64(len(update))) |
| 512 | if err != nil { |
| 513 | return fmt.Errorf("alloc: %w", err) |
| 514 | } |
| 515 | defer freeIn(ctx, timeout, mod, ptr, uint64(len(update))) |
| 516 | |
| 517 | if !mod.Memory().Write(uint32(ptr), update) { |
| 518 | return fmt.Errorf("writing %d bytes at %d: out of range", len(update), ptr) |
| 519 | } |
| 520 | |
| 521 | rc, err := callGuest(ctx, timeout, mod.ExportedFunction("docs_core_apply"), docHandle, ptr, uint64(len(update))) |
| 522 | if err != nil { |
| 523 | return fmt.Errorf("apply trapped or timed out: %w", err) |
| 524 | } |
| 525 | if code := int32(rc[0]); code != 0 { |
| 526 | return fmt.Errorf("apply rejected the update: code %d", code) |
| 527 | } |
| 528 | return nil |
| 529 | } |
| 530 | |
| 531 | // Apply applies update to the document. A clean rejection (a negative |
| 532 | // error code from the guest, e.g. malformed wire bytes that yrs's decoder |
| 533 | // catches) leaves the instance untouched and fully usable — that is the |
| 534 | // whole point of §5.2's per-document instance: most bad input never needs |
| 535 | // the instance discarded at all. That case comes back as a *RejectedError |
| 536 | // (see Rejected). A trap or a deadline exceeded is different: the |
| 537 | // instance is discarded and rebuilt from every update applied here so |
| 538 | // far, and the triggering update is reported as an error but never |
| 539 | // retried or logged, so it can never itself be replayed into the rebuilt |
| 540 | // instance. When that rebuild succeeds, the returned error is a |
| 541 | // *RecoveredError (see Recovered) so a caller can tell "this update was |
| 542 | // rejected but the document is fine" apart from "the document itself is |
| 543 | // gone" (a rebuild that fails returns a plain joined error instead — the |
| 544 | // one shape this method returns that is neither *RejectedError nor |
| 545 | // *RecoveredError, and the one a caller must not mistake for either: the |
| 546 | // document itself may now be unusable, not merely this one update). |
| 547 | // |
| 548 | // Once the document's retained update log is full (maxRetainedUpdates), |
| 549 | // Apply refuses any further update — including one that would otherwise |
| 550 | // apply cleanly — with ErrUpdateLogFull, rather than growing the log |
| 551 | // without bound. See maxRetainedUpdates's doc comment. |
| 552 | func (h *Handle) Apply(ctx context.Context, update []byte) error { |
| 553 | if len(h.applied) >= maxRetainedUpdates { |
| 554 | return ErrUpdateLogFull |
| 555 | } |
| 556 | |
| 557 | err := applyOn(ctx, h.rt.callTimeout, h.mod, h.handle, update) |
| 558 | if err == nil { |
| 559 | // Copy: the caller may reuse or mutate its update slice after this |
| 560 | // call returns, and this log must survive a later rebuild. |
| 561 | stored := make([]byte, len(update)) |
| 562 | copy(stored, update) |
| 563 | h.applied = append(h.applied, stored) |
| 564 | return nil |
| 565 | } |
| 566 | if isFatal(err) { |
| 567 | if rebuildErr := h.rebuild(ctx); rebuildErr != nil { |
| 568 | return errors.Join(err, rebuildErr) |
| 569 | } |
| 570 | return &RecoveredError{err} |
| 571 | } |
| 572 | return &RejectedError{err} |
| 573 | } |
| 574 | |
| 575 | // Text returns the document's current text content. If the read itself |
| 576 | // traps or times out, the instance is discarded and rebuilt exactly as in |
| 577 | // Apply, and the read is retried once against the freshly rebuilt |
| 578 | // instance — the content is available at that point, and returning an |
| 579 | // empty string plus an error instead of it would tell the caller the data |
| 580 | // is gone when it is not. |
| 581 | func (h *Handle) Text(ctx context.Context) (string, error) { |
| 582 | return h.read(ctx, "text") |
| 583 | } |
| 584 | |
| 585 | // HTML returns the document's current content rendered as HTML (spec |
| 586 | // §6.2: the read view is server-rendered, no script required). |
| 587 | func (h *Handle) HTML(ctx context.Context) (string, error) { |
| 588 | return h.read(ctx, "html") |
| 589 | } |
| 590 | |
| 591 | // JSON returns the document's structure as ProseMirror-shaped JSON — |
| 592 | // what the export paths (internal/export, internal/pdf) read, since |
| 593 | // Markdown and PDF both need to know where a list ends and which run is |
| 594 | // bold, and neither can get that from HTML without a parser. See |
| 595 | // core/src/fragment.rs's fragment_json. |
| 596 | // |
| 597 | // The bytes are always a complete JSON document for a live handle, |
| 598 | // never "" — an empty document is {"type":"doc","content":[]} — so a |
| 599 | // caller may unmarshal the result without a special case for empty. |
| 600 | func (h *Handle) JSON(ctx context.Context) (string, error) { |
| 601 | return h.read(ctx, "json") |
| 602 | } |
| 603 | |
| 604 | // Snapshot returns the document's entire state as one Yjs update — what |
| 605 | // doc_snapshots stores so a later replay can start from it instead of |
| 606 | // from the whole log (the snapshots design, D1). |
| 607 | // |
| 608 | // Bytes rather than a string, but it rides the same len/read machinery |
| 609 | // as Text/HTML/JSON, including their trap-and-retry: a Go string is a |
| 610 | // byte sequence and nothing here interprets it, so "snapshot" is simply |
| 611 | // a fourth renderer name to readOn. |
| 612 | func (h *Handle) Snapshot(ctx context.Context) ([]byte, error) { |
| 613 | s, err := h.read(ctx, "snapshot") |
| 614 | if err != nil { |
| 615 | return nil, err |
| 616 | } |
| 617 | return []byte(s), nil |
| 618 | } |
| 619 | |
| 620 | // verifySnapshotOn allocates candidate into mod's linear memory, calls |
| 621 | // docs_core_verify_snapshot against docHandle, frees the buffer, and |
| 622 | // returns the raw i32 return code. Structured exactly like applyOn — |
| 623 | // docs_core_verify_snapshot is the only other export besides |
| 624 | // docs_core_apply that takes a caller-supplied byte buffer into guest |
| 625 | // memory, so the alloc/write/call/free dance and its error handling are |
| 626 | // copied from there rather than reinvented. |
| 627 | func verifySnapshotOn(ctx context.Context, timeout time.Duration, mod api.Module, docHandle uint64, candidate []byte) (int32, error) { |
| 628 | ptr, err := allocIn(ctx, timeout, mod, uint64(len(candidate))) |
| 629 | if err != nil { |
| 630 | return 0, fmt.Errorf("alloc: %w", err) |
| 631 | } |
| 632 | defer freeIn(ctx, timeout, mod, ptr, uint64(len(candidate))) |
| 633 | |
| 634 | if !mod.Memory().Write(uint32(ptr), candidate) { |
| 635 | return 0, fmt.Errorf("writing %d bytes at %d: out of range", len(candidate), ptr) |
| 636 | } |
| 637 | |
| 638 | rc, err := callGuest(ctx, timeout, mod.ExportedFunction("docs_core_verify_snapshot"), docHandle, ptr, uint64(len(candidate))) |
| 639 | if err != nil { |
| 640 | return 0, fmt.Errorf("verify_snapshot trapped or timed out: %w", err) |
| 641 | } |
| 642 | return int32(rc[0]), nil |
| 643 | } |
| 644 | |
| 645 | // VerifySnapshot asks the core whether candidate, replayed into an empty |
| 646 | // document, is this document — state vector, delete set and structural |
| 647 | // render (the snapshots design, D5). It is a smoke test rather than a |
| 648 | // proof, and it cannot detect a snapshot taken from a stale state; see |
| 649 | // core/src/snapshot.rs for both limits. |
| 650 | // |
| 651 | // docs_core_verify_snapshot's four outcomes map as: 0 (the candidate is |
| 652 | // this document) -> true, nil; 1 (it is not) -> false, nil; ERR_DECODE |
| 653 | // (candidate is not a decodable update at all) -> false, nil — bad bytes |
| 654 | // are a "no" answer, not a failure of this call; anything else (a trap, |
| 655 | // a deadline exceeded, ERR_NO_DOC, ERR_INVALID_LENGTH, or an |
| 656 | // unrecognised code) -> false, error. |
| 657 | // |
| 658 | // A trap or timeout is handled exactly as in Apply: the instance is |
| 659 | // discarded and rebuilt from every update applied here so far (the |
| 660 | // document itself survives), and the returned error is a *RecoveredError |
| 661 | // so a caller can tell that apart from a rebuild that itself failed. |
| 662 | // Unlike Apply, there is no update to omit from replay — this call never |
| 663 | // mutates h's document at all, only a throwaway one inside the guest. |
| 664 | func (h *Handle) VerifySnapshot(ctx context.Context, candidate []byte) (bool, error) { |
| 665 | const errDecode = -2 |
| 666 | |
| 667 | code, err := verifySnapshotOn(ctx, h.rt.callTimeout, h.mod, h.handle, candidate) |
| 668 | if err != nil { |
| 669 | if isFatal(err) { |
| 670 | if rebuildErr := h.rebuild(ctx); rebuildErr != nil { |
| 671 | return false, errors.Join(err, rebuildErr) |
| 672 | } |
| 673 | return false, &RecoveredError{err} |
| 674 | } |
| 675 | return false, err |
| 676 | } |
| 677 | |
| 678 | switch code { |
| 679 | case 0: |
| 680 | return true, nil |
| 681 | case 1: |
| 682 | return false, nil |
| 683 | case errDecode: |
| 684 | return false, nil |
| 685 | default: |
| 686 | return false, fmt.Errorf("verify_snapshot failed: code %d", code) |
| 687 | } |
| 688 | } |
| 689 | |
| 690 | // read is the shared body of Text, HTML, JSON and Snapshot: call the named |
| 691 | // renderer's len/read pair, and if the call itself traps or times out, |
| 692 | // discard the instance, rebuild it exactly as Apply does, and retry the |
| 693 | // read once against the fresh one. Retrying rather than reporting an |
| 694 | // error is deliberate — the content is available at that point, and |
| 695 | // returning an empty string plus an error would tell the caller the |
| 696 | // data is gone when it is not. |
| 697 | // |
| 698 | // One function rather than three near-identical ones. It was three |
| 699 | // (textOn/htmlOn, and jsonOn was about to be the third): the same forty |
| 700 | // lines with two identifiers substituted, which is three places for a |
| 701 | // fix like fix-round-1's finding 3 (read the written count, not the |
| 702 | // buffer size) to be applied to two of. |
| 703 | func (h *Handle) read(ctx context.Context, renderer string) (string, error) { |
| 704 | s, err := readOn(ctx, h.rt.callTimeout, h.mod, h.handle, renderer) |
| 705 | if err == nil { |
| 706 | return s, nil |
| 707 | } |
| 708 | if !isFatal(err) { |
| 709 | return "", err |
| 710 | } |
| 711 | if rebuildErr := h.rebuild(ctx); rebuildErr != nil { |
| 712 | return "", errors.Join(err, rebuildErr) |
| 713 | } |
| 714 | s, err = readOn(ctx, h.rt.callTimeout, h.mod, h.handle, renderer) |
| 715 | if err != nil { |
| 716 | return "", fmt.Errorf("reading %s from the rebuilt instance: %w", renderer, err) |
| 717 | } |
| 718 | return s, nil |
| 719 | } |
| 720 | |
| 721 | // readOn performs one renderer's len-then-read dance against |
| 722 | // docs_core_<renderer>_len and docs_core_<renderer>_read. renderer is |
| 723 | // always one of this package's own four literals ("text", "html", |
| 724 | // "json", "snapshot"), never anything derived from a request — the |
| 725 | // export names it builds are a closed set, and a name that does not |
| 726 | // resolve to an exported function surfaces below as a nil api.Function. |
| 727 | func readOn(ctx context.Context, timeout time.Duration, mod api.Module, docHandle uint64, renderer string) (string, error) { |
| 728 | lenFn := mod.ExportedFunction("docs_core_" + renderer + "_len") |
| 729 | readFn := mod.ExportedFunction("docs_core_" + renderer + "_read") |
| 730 | if lenFn == nil || readFn == nil { |
| 731 | // Unreachable while abiVersion matches — the version check at |
| 732 | // load is what proves these exports exist — but a nil |
| 733 | // api.Function panics inside callGuest rather than erroring, so |
| 734 | // this turns a future ABI mistake into a returned error instead |
| 735 | // of a panic in a request handler. |
| 736 | return "", fmt.Errorf("module exports no %q renderer", renderer) |
| 737 | } |
| 738 | |
| 739 | lenRes, err := callGuest(ctx, timeout, lenFn, docHandle) |
| 740 | if err != nil { |
| 741 | return "", fmt.Errorf("%s_len trapped or timed out: %w", renderer, err) |
| 742 | } |
| 743 | // The len functions return i32: negative is an error code, not a |
| 744 | // length. Reading it as unsigned would turn ERR_NO_DOC into |
| 745 | // 0xFFFFFFFF and then try to allocate four gigabytes. |
| 746 | if code := int32(lenRes[0]); code < 0 { |
| 747 | return "", fmt.Errorf("%s_len failed: code %d", renderer, code) |
| 748 | } |
| 749 | n := lenRes[0] |
| 750 | if n == 0 { |
| 751 | return "", nil |
| 752 | } |
| 753 | |
| 754 | ptr, err := allocIn(ctx, timeout, mod, n) |
| 755 | if err != nil { |
| 756 | return "", fmt.Errorf("alloc: %w", err) |
| 757 | } |
| 758 | defer freeIn(ctx, timeout, mod, ptr, n) |
| 759 | |
| 760 | rc, err := callGuest(ctx, timeout, readFn, docHandle, ptr, n) |
| 761 | if err != nil { |
| 762 | return "", fmt.Errorf("%s_read trapped or timed out: %w", renderer, err) |
| 763 | } |
| 764 | written := int32(rc[0]) |
| 765 | if written < 0 { |
| 766 | return "", fmt.Errorf("%s_read failed: code %d", renderer, written) |
| 767 | } |
| 768 | |
| 769 | // The read functions return s.len(), guaranteed only <= out_len |
| 770 | // (core/src/abi.rs — the buffer sized by the len call is an upper |
| 771 | // bound, not a promise the two calls agree exactly). Reading n bytes |
| 772 | // instead of the written count would, if they ever diverged, append |
| 773 | // uninitialised allocator bytes past the real content to the |
| 774 | // returned string (fix-round-1 finding 3) — read exactly what the |
| 775 | // read call says it wrote. |
| 776 | buf, ok := mod.Memory().Read(uint32(ptr), uint32(written)) |
| 777 | if !ok { |
| 778 | return "", fmt.Errorf("reading %d bytes at %d: out of range", written, ptr) |
| 779 | } |
| 780 | return string(buf), nil |
| 781 | } |
| 782 | |