| 1 | // Package core hosts the Rust deck 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 | // and a trap discards that instance rather than reusing it. |
| 17 | // |
| 18 | // Separately, a malformed update need not trap at all: Docs' fuzzing found |
| 19 | // a 36-byte corrupted update that ran the apply export for 163 real CPU |
| 20 | // seconds under an interpreter before returning an ordinary, correct |
| 21 | // answer — not a hang, but on a server accepting updates from anyone with |
| 22 | // a share link it is a denial-of-service vector no correctness test can |
| 23 | // see. Every guest call here runs under a context.WithTimeout (default one |
| 24 | // second, tunable per Runtime via WithGuestCallTimeout), and the Runtime is |
| 25 | // built with WithCloseOnContextDone(true) so wazero actually terminates a |
| 26 | // call that overruns its budget rather than merely making the caller stop |
| 27 | // waiting. A deadline exceeded is treated exactly like a trap: the instance |
| 28 | // is discarded and a fresh one is rebuilt. |
| 29 | // |
| 30 | // # Rebuilding is log replay |
| 31 | // |
| 32 | // Every Handle keeps, in memory, every update it has itself applied |
| 33 | // successfully — the bytes given to Apply, and the bytes Ops produced — |
| 34 | // up to maxRetainedUpdates, and a discarded instance is rebuilt by |
| 35 | // replaying that log into a fresh document. Nothing in here is persisted; |
| 36 | // starting a document from a stored snapshot (Handle.State's bytes) plus |
| 37 | // the updates since is the caller's arrangement, exactly as in Docs, and |
| 38 | // the cap on the in-memory log exists because within this package |
| 39 | // "replay everything" is the only recovery there is. |
| 40 | package core |
| 41 | |
| 42 | import ( |
| 43 | "context" |
| 44 | _ "embed" |
| 45 | "errors" |
| 46 | "fmt" |
| 47 | "time" |
| 48 | |
| 49 | "github.com/tetratelabs/wazero" |
| 50 | "github.com/tetratelabs/wazero/api" |
| 51 | ) |
| 52 | |
| 53 | //go:embed testdata/slides_core.wasm |
| 54 | var moduleWASM []byte |
| 55 | |
| 56 | // abiVersion is the contract this host understands. A module reporting |
| 57 | // anything else is refused at load, so a stale .wasm (see |
| 58 | // scripts/build-wasm.sh and the copy into testdata) fails loudly rather |
| 59 | // than behaving strangely. It matches core/src/lib.rs's |
| 60 | // slides_core_abi_version, which explains when it must move. Version 2 |
| 61 | // added slides_core_check (codex 3). |
| 62 | const abiVersion = 2 |
| 63 | |
| 64 | // ABIVersion is abiVersion, exported for callers outside this package that |
| 65 | // need to know which ABI contract this host was built against. It is the |
| 66 | // same constant, not a second number that could drift from it. |
| 67 | const ABIVersion = abiVersion |
| 68 | |
| 69 | // defaultGuestCallTimeout bounds every single call into the wasm module, |
| 70 | // unless overridden with WithGuestCallTimeout. One second keeps roughly |
| 71 | // 1000x headroom over real decks (sub-millisecond) while cutting off |
| 72 | // Docs' measured pathological input (~4.3s under wazero) at about a |
| 73 | // quarter of its natural running time. |
| 74 | const defaultGuestCallTimeout = 1 * time.Second |
| 75 | |
| 76 | // maxRetainedUpdates bounds how many previously-applied updates a Handle |
| 77 | // will keep for the log-replay recovery described above. Once a |
| 78 | // document's log is full, Apply and Ops refuse further work with |
| 79 | // ErrUpdateLogFull rather than either growing the log without bound or |
| 80 | // silently dropping old entries — dropping old entries would make a later |
| 81 | // rebuild replay an incomplete history and produce silently wrong content, |
| 82 | // which is worse than refusing. The real fix for a document that keeps |
| 83 | // being edited past that point is a snapshot (Handle.State) persisted by |
| 84 | // the caller and replayed from, not a bigger number here. |
| 85 | const maxRetainedUpdates = 4096 |
| 86 | |
| 87 | // MaxRetainedUpdates is maxRetainedUpdates, exported for callers outside |
| 88 | // this package that need the same cap. |
| 89 | const MaxRetainedUpdates = maxRetainedUpdates |
| 90 | |
| 91 | // ErrUpdateLogFull is returned by Handle.Apply and Handle.Ops once a |
| 92 | // document's retained update log has reached maxRetainedUpdates. See that |
| 93 | // constant's doc comment. |
| 94 | var ErrUpdateLogFull = errors.New("update log exceeds the retained limit; the document needs a snapshot before it can accept further updates") |
| 95 | |
| 96 | // defaultMemoryPages caps the linear memory ONE document instance may |
| 97 | // grow to: 4096 wasm pages of 64 KiB, which is 256 MiB. |
| 98 | // |
| 99 | // wasm32 memory grows to 4 GiB unless the host says otherwise, and this |
| 100 | // host holds one instance per open document on an instance shared with |
| 101 | // every other request. A document is text and structure — the measured |
| 102 | // working set of a real deck is under a megabyte — so a quarter of a |
| 103 | // gibibyte is far above anything an edit needs and far below anything |
| 104 | // that would take the process down. Half of pdfcore's cap, because a |
| 105 | // document is small and a PDF render is not. |
| 106 | // |
| 107 | // Past the cap `memory.grow` answers -1 inside the module, the Rust |
| 108 | // allocator aborts, and wazero reports a trap: Apply and every other |
| 109 | // call return an ordinary error, the Handle is rebuilt by replay like |
| 110 | // any other trap, and the process is untouched. |
| 111 | const defaultMemoryPages = 4096 |
| 112 | |
| 113 | // Option configures a Runtime. See WithGuestCallTimeout. |
| 114 | type Option func(*runtimeOptions) |
| 115 | |
| 116 | type runtimeOptions struct { |
| 117 | callTimeout time.Duration |
| 118 | memoryPages uint32 |
| 119 | } |
| 120 | |
| 121 | // WithGuestCallTimeout overrides defaultGuestCallTimeout for one Runtime. |
| 122 | // Exists so the cap can be tuned (for a slower/faster production host, or |
| 123 | // for a test that wants a tight budget without waiting out the default) in |
| 124 | // a config value instead of a recompile. |
| 125 | func WithGuestCallTimeout(d time.Duration) Option { |
| 126 | return func(o *runtimeOptions) { o.callTimeout = d } |
| 127 | } |
| 128 | |
| 129 | // WithMemoryLimitPages overrides defaultMemoryPages for one Runtime, so |
| 130 | // the ceiling can be tuned on a bigger host and a test can reach it |
| 131 | // without allocating a quarter of a gibibyte first. |
| 132 | func WithMemoryLimitPages(pages uint32) Option { |
| 133 | return func(o *runtimeOptions) { o.memoryPages = pages } |
| 134 | } |
| 135 | |
| 136 | // Runtime owns the compiled module. Compiling is expensive and |
| 137 | // instantiating is not, so one Runtime is shared and each document gets its |
| 138 | // own instance (instances are not safe for concurrent use). |
| 139 | type Runtime struct { |
| 140 | rt wazero.Runtime |
| 141 | compiled wazero.CompiledModule |
| 142 | |
| 143 | // callTimeout bounds every guest call made through this Runtime and |
| 144 | // every Handle it creates. See defaultGuestCallTimeout and |
| 145 | // WithGuestCallTimeout. |
| 146 | callTimeout time.Duration |
| 147 | } |
| 148 | |
| 149 | func New(ctx context.Context, opts ...Option) (*Runtime, error) { |
| 150 | o := runtimeOptions{callTimeout: defaultGuestCallTimeout, memoryPages: defaultMemoryPages} |
| 151 | for _, opt := range opts { |
| 152 | opt(&o) |
| 153 | } |
| 154 | |
| 155 | // WithCloseOnContextDone(true): without this, a context deadline or |
| 156 | // cancellation on a guest call only makes the *caller* stop waiting — |
| 157 | // the underlying goroutine executing wasm keeps running to completion. |
| 158 | // With it, wazero itself terminates the call and closes the module |
| 159 | // when the context is done, which is what turns a pathologically slow |
| 160 | // input into a bounded failure instead of a resource leak. |
| 161 | // |
| 162 | // WithMemoryLimitPages is the same containment argument about the |
| 163 | // other resource: the timeout bounds how long one call may run, the |
| 164 | // page limit bounds how much memory a document may take with it |
| 165 | // (defaultMemoryPages). |
| 166 | cfg := wazero.NewRuntimeConfig(). |
| 167 | WithCloseOnContextDone(true). |
| 168 | WithMemoryLimitPages(o.memoryPages) |
| 169 | rt := wazero.NewRuntimeWithConfig(ctx, cfg) |
| 170 | compiled, err := rt.CompileModule(ctx, moduleWASM) |
| 171 | if err != nil { |
| 172 | rt.Close(ctx) |
| 173 | return nil, fmt.Errorf("compiling core module: %w", err) |
| 174 | } |
| 175 | return &Runtime{rt: rt, compiled: compiled, callTimeout: o.callTimeout}, nil |
| 176 | } |
| 177 | |
| 178 | func (r *Runtime) Close(ctx context.Context) error { return r.rt.Close(ctx) } |
| 179 | |
| 180 | // instantiate creates a fresh module instance and checks its reported ABI |
| 181 | // version. Used both for a brand-new document and to rebuild after a |
| 182 | // discarded (trapped or timed-out) instance. |
| 183 | func (r *Runtime) instantiate(ctx context.Context) (api.Module, error) { |
| 184 | cctx, cancel := context.WithTimeout(ctx, r.callTimeout) |
| 185 | defer cancel() |
| 186 | // Empty name: wazero treats an anonymous module (ModuleName == "") as |
| 187 | // exempt from its store's name-uniqueness check, so instantiating many |
| 188 | // of these concurrently — one per document, plus rebuilds — never |
| 189 | // collides. |
| 190 | mod, err := r.rt.InstantiateModule(cctx, r.compiled, wazero.NewModuleConfig().WithName("")) |
| 191 | if err != nil { |
| 192 | return nil, fmt.Errorf("instantiating core module: %w", err) |
| 193 | } |
| 194 | |
| 195 | version, err := callGuest(ctx, r.callTimeout, mod.ExportedFunction("slides_core_abi_version")) |
| 196 | if err != nil { |
| 197 | mod.Close(ctx) |
| 198 | return nil, fmt.Errorf("reading abi version: %w", err) |
| 199 | } |
| 200 | if err := checkABIVersion(uint32(version[0]), abiVersion); err != nil { |
| 201 | mod.Close(ctx) |
| 202 | return nil, err |
| 203 | } |
| 204 | return mod, nil |
| 205 | } |
| 206 | |
| 207 | // checkABIVersion reports whether reported (what a module's |
| 208 | // slides_core_abi_version export returned) matches want (abiVersion, the |
| 209 | // contract this host understands). A pure function so the decision itself |
| 210 | // is unit-testable without a compiled module standing in for every case. |
| 211 | // |
| 212 | // The error text names both numbers deliberately: whoever hits this in |
| 213 | // production needs to know which side is stale (an old host against a |
| 214 | // rebuilt module, or vice versa) without also reading wasm build logs. |
| 215 | func checkABIVersion(reported, want uint32) error { |
| 216 | if reported != want { |
| 217 | return fmt.Errorf("core module reports ABI %d, host understands %d", reported, want) |
| 218 | } |
| 219 | return nil |
| 220 | } |
| 221 | |
| 222 | // callGuest calls fn under the given timeout. A non-nil error here means |
| 223 | // the call trapped, ran past its deadline (in which case wazero has |
| 224 | // already closed the module — WithCloseOnContextDone(true)), or the |
| 225 | // module was otherwise closed out from under it: in every case the |
| 226 | // instance must be treated as poisoned, never merely retried. The error is |
| 227 | // wrapped in trapFault so callers further up can tell it apart from an |
| 228 | // ordinary clean rejection (a negative return code from the guest), which |
| 229 | // does not poison anything — see isFatal. |
| 230 | func callGuest(ctx context.Context, timeout time.Duration, fn api.Function, params ...uint64) ([]uint64, error) { |
| 231 | cctx, cancel := context.WithTimeout(ctx, timeout) |
| 232 | defer cancel() |
| 233 | res, err := fn.Call(cctx, params...) |
| 234 | if err != nil { |
| 235 | return nil, &trapFault{err} |
| 236 | } |
| 237 | return res, nil |
| 238 | } |
| 239 | |
| 240 | // trapFault marks an error as meaning the module instance itself is no |
| 241 | // longer trustworthy: the wasm call that produced it did not return |
| 242 | // normally at all (a trap, a deadline exceeded, or the module having |
| 243 | // already been closed), as opposed to returning normally with a negative |
| 244 | // application error code. Every error that originates from a wazero |
| 245 | // api.Function.Call failing carries this; every error this package |
| 246 | // constructs itself from a clean negative return code does not, so |
| 247 | // isFatal (via errors.As, walking any fmt.Errorf("...: %w", err) wrapping) |
| 248 | // tells the two apart. |
| 249 | type trapFault struct{ err error } |
| 250 | |
| 251 | func (t *trapFault) Error() string { return t.err.Error() } |
| 252 | func (t *trapFault) Unwrap() error { return t.err } |
| 253 | |
| 254 | // isFatal reports whether err means the module instance must be discarded |
| 255 | // (a trap or a deadline exceeded reaching all the way from callGuest) as |
| 256 | // opposed to an ordinary clean rejection, which leaves the instance fully |
| 257 | // usable. |
| 258 | func isFatal(err error) bool { |
| 259 | var t *trapFault |
| 260 | return errors.As(err, &t) |
| 261 | } |
| 262 | |
| 263 | // RecoveredError wraps the error from a call whose instance trapped or |
| 264 | // timed out but was then successfully discarded and rebuilt — so the |
| 265 | // *document* is intact (every previously-applied update survived), even |
| 266 | // though this specific call was not (its outcome is unknown; it is never |
| 267 | // retried). Callers that only check "err != nil" cannot otherwise tell |
| 268 | // that apart from an ordinary clean rejection (where the instance was |
| 269 | // never touched) or from a rebuild that itself failed (a plain joined |
| 270 | // error, where there is no usable instance at all). Use Recovered(err) |
| 271 | // rather than a type assertion. |
| 272 | type RecoveredError struct{ err error } |
| 273 | |
| 274 | func (e *RecoveredError) Error() string { return e.err.Error() } |
| 275 | func (e *RecoveredError) Unwrap() error { return e.err } |
| 276 | |
| 277 | // Recovered reports whether err came from a call that trapped or timed |
| 278 | // out but whose instance was successfully discarded and rebuilt — see |
| 279 | // RecoveredError. |
| 280 | func Recovered(err error) bool { |
| 281 | var r *RecoveredError |
| 282 | return errors.As(err, &r) |
| 283 | } |
| 284 | |
| 285 | // RejectedError wraps the error from a call the guest cleanly refused — |
| 286 | // a negative return code, such as malformed wire bytes yrs's decoder |
| 287 | // catches, or an op batch the core would not apply — without the |
| 288 | // instance ever being touched: the document's existing content is |
| 289 | // completely unaffected, and the instance is exactly as usable afterward |
| 290 | // as it was before the call. This is the ordinary, expected way bad input |
| 291 | // is refused. For Ops the message is the core's own, verbatim, so a |
| 292 | // caller can hand it back to whoever sent the batch. Use Rejected(err) |
| 293 | // rather than a type assertion. |
| 294 | type RejectedError struct{ err error } |
| 295 | |
| 296 | func (e *RejectedError) Error() string { return e.err.Error() } |
| 297 | func (e *RejectedError) Unwrap() error { return e.err } |
| 298 | |
| 299 | // Rejected reports whether err came from a call that was cleanly refused |
| 300 | // with the instance left fully usable — see RejectedError. |
| 301 | func Rejected(err error) bool { |
| 302 | var r *RejectedError |
| 303 | return errors.As(err, &r) |
| 304 | } |
| 305 | |
| 306 | // Handle is one document, backed by its own module instance. Not safe for |
| 307 | // concurrent use; callers serialise access per document. |
| 308 | // |
| 309 | // applied records, in order, every update this handle has itself applied |
| 310 | // successfully — the bytes given to Apply, and the bytes Ops produced — |
| 311 | // up to maxRetainedUpdates. It exists solely so a trapped or timed-out |
| 312 | // instance can be discarded and a fresh one rebuilt by replaying only the |
| 313 | // updates already known to be good; the call that triggered the discard |
| 314 | // is never replayed. |
| 315 | type Handle struct { |
| 316 | rt *Runtime |
| 317 | guid string |
| 318 | clientID uint64 |
| 319 | |
| 320 | mod api.Module |
| 321 | handle uint64 |
| 322 | |
| 323 | applied [][]byte |
| 324 | } |
| 325 | |
| 326 | // NewDoc creates a document. The GUID and client id are supplied by the |
| 327 | // caller and never drawn from yrs: on wasm the CRDT library's entropy is a |
| 328 | // constant seed, so a value it generates is identical in every process, and |
| 329 | // a repeated client id or GUID corrupts the document. |
| 330 | func (r *Runtime) NewDoc(ctx context.Context, guid string, clientID uint64) (*Handle, error) { |
| 331 | mod, err := r.instantiate(ctx) |
| 332 | if err != nil { |
| 333 | return nil, err |
| 334 | } |
| 335 | |
| 336 | handle := &Handle{rt: r, guid: guid, clientID: clientID, mod: mod} |
| 337 | docHandle, err := createDoc(ctx, r.callTimeout, mod, guid, clientID) |
| 338 | if err != nil { |
| 339 | mod.Close(ctx) |
| 340 | return nil, err |
| 341 | } |
| 342 | handle.handle = docHandle |
| 343 | return handle, nil |
| 344 | } |
| 345 | |
| 346 | // createDoc allocates the guid in mod's linear memory, calls |
| 347 | // slides_core_doc_new, and frees the buffer. Shared between NewDoc and a |
| 348 | // post-trap rebuild. |
| 349 | func createDoc(ctx context.Context, timeout time.Duration, mod api.Module, guid string, clientID uint64) (uint64, error) { |
| 350 | guidBytes := []byte(guid) |
| 351 | ptr, err := allocIn(ctx, timeout, mod, uint64(len(guidBytes))) |
| 352 | if err != nil { |
| 353 | return 0, fmt.Errorf("alloc for guid: %w", err) |
| 354 | } |
| 355 | defer freeIn(ctx, timeout, mod, ptr, uint64(len(guidBytes))) |
| 356 | |
| 357 | if !mod.Memory().Write(uint32(ptr), guidBytes) { |
| 358 | return 0, fmt.Errorf("writing guid at %d: out of range", ptr) |
| 359 | } |
| 360 | |
| 361 | res, err := callGuest(ctx, timeout, mod.ExportedFunction("slides_core_doc_new"), ptr, uint64(len(guidBytes)), clientID) |
| 362 | if err != nil { |
| 363 | return 0, fmt.Errorf("doc_new: %w", err) |
| 364 | } |
| 365 | if res[0] == 0 { |
| 366 | return 0, fmt.Errorf("doc_new returned a null handle") |
| 367 | } |
| 368 | return res[0], nil |
| 369 | } |
| 370 | |
| 371 | // allocIn calls slides_core_alloc and checks the 0-on-failure sentinel |
| 372 | // (including any length over isize::MAX — core/src/abi.rs's layout_for). |
| 373 | func allocIn(ctx context.Context, timeout time.Duration, mod api.Module, length uint64) (uint64, error) { |
| 374 | res, err := callGuest(ctx, timeout, mod.ExportedFunction("slides_core_alloc"), length) |
| 375 | if err != nil { |
| 376 | return 0, fmt.Errorf("alloc trapped or timed out: %w", err) |
| 377 | } |
| 378 | if res[0] == 0 { |
| 379 | return 0, fmt.Errorf("alloc(%d) failed", length) |
| 380 | } |
| 381 | return res[0], nil |
| 382 | } |
| 383 | |
| 384 | // freeIn best-efforts a free. A failure here (trap/timeout) is folded into |
| 385 | // "the instance is now poisoned", which the caller's own callGuest error |
| 386 | // (the one that actually matters) will already have triggered, so this |
| 387 | // deliberately does not itself return an error to plumb through defers. |
| 388 | func freeIn(ctx context.Context, timeout time.Duration, mod api.Module, ptr, length uint64) { |
| 389 | _, _ = callGuest(ctx, timeout, mod.ExportedFunction("slides_core_free"), ptr, length) |
| 390 | } |
| 391 | |
| 392 | func (h *Handle) Close(ctx context.Context) error { |
| 393 | _, err := callGuest(ctx, h.rt.callTimeout, h.mod.ExportedFunction("slides_core_doc_free"), h.handle) |
| 394 | if closeErr := h.mod.Close(ctx); err == nil { |
| 395 | err = closeErr |
| 396 | } |
| 397 | return err |
| 398 | } |
| 399 | |
| 400 | // rebuild discards h's current (poisoned) instance and replaces it with a |
| 401 | // fresh one for the same document (guid, clientID), replaying every update |
| 402 | // previously applied successfully. The call that triggered the discard is |
| 403 | // not replayed — it is not in h.applied, since that log is only appended |
| 404 | // to on a clean success. |
| 405 | // |
| 406 | // It deliberately does not run under ctx as given: ctx is the same context |
| 407 | // whose deadline may be exactly what triggered this rebuild. Rebuilding |
| 408 | // under an already-expired deadline would make recovery fail precisely in |
| 409 | // the case it exists for, so this strips ctx's cancellation/deadline |
| 410 | // (context.WithoutCancel) while keeping any values on it, and relies on |
| 411 | // callTimeout to still bound each individual call made during the rebuild |
| 412 | // itself. |
| 413 | func (h *Handle) rebuild(ctx context.Context) error { |
| 414 | rebuildCtx := context.WithoutCancel(ctx) |
| 415 | timeout := h.rt.callTimeout |
| 416 | |
| 417 | // Best-effort: the old instance may already be closed (a timeout under |
| 418 | // WithCloseOnContextDone(true) closes it as part of terminating the |
| 419 | // call), so an error here is expected and not itself a failure. |
| 420 | _ = h.mod.Close(rebuildCtx) |
| 421 | |
| 422 | mod, err := h.rt.instantiate(rebuildCtx) |
| 423 | if err != nil { |
| 424 | return fmt.Errorf("rebuilding instance: %w", err) |
| 425 | } |
| 426 | docHandle, err := createDoc(rebuildCtx, timeout, mod, h.guid, h.clientID) |
| 427 | if err != nil { |
| 428 | mod.Close(rebuildCtx) |
| 429 | return fmt.Errorf("rebuilding instance: %w", err) |
| 430 | } |
| 431 | for i, update := range h.applied { |
| 432 | if err := applyOn(rebuildCtx, timeout, mod, docHandle, update); err != nil { |
| 433 | mod.Close(rebuildCtx) |
| 434 | return fmt.Errorf("rebuilding instance: replaying update %d/%d, previously applied cleanly: %w", |
| 435 | i+1, len(h.applied), err) |
| 436 | } |
| 437 | } |
| 438 | h.mod = mod |
| 439 | h.handle = docHandle |
| 440 | return nil |
| 441 | } |
| 442 | |
| 443 | // withBuffer copies payload into mod's linear memory, calls fn with the |
| 444 | // buffer's (ptr, len), and frees the buffer afterwards. The alloc/write/ |
| 445 | // call/free dance every export taking caller bytes needs, in one place. |
| 446 | func withBuffer(ctx context.Context, timeout time.Duration, mod api.Module, payload []byte, fn func(ptr, length uint64) error) error { |
| 447 | ptr, err := allocIn(ctx, timeout, mod, uint64(len(payload))) |
| 448 | if err != nil { |
| 449 | return fmt.Errorf("alloc: %w", err) |
| 450 | } |
| 451 | defer freeIn(ctx, timeout, mod, ptr, uint64(len(payload))) |
| 452 | |
| 453 | if !mod.Memory().Write(uint32(ptr), payload) { |
| 454 | return fmt.Errorf("writing %d bytes at %d: out of range", len(payload), ptr) |
| 455 | } |
| 456 | return fn(ptr, uint64(len(payload))) |
| 457 | } |
| 458 | |
| 459 | // applyOn runs slides_core_apply against a specific (mod, docHandle) pair — |
| 460 | // factored out so both Handle.Apply and rebuild's replay share the same |
| 461 | // logic. |
| 462 | func applyOn(ctx context.Context, timeout time.Duration, mod api.Module, docHandle uint64, update []byte) error { |
| 463 | return withBuffer(ctx, timeout, mod, update, func(ptr, length uint64) error { |
| 464 | rc, err := callGuest(ctx, timeout, mod.ExportedFunction("slides_core_apply"), docHandle, ptr, length) |
| 465 | if err != nil { |
| 466 | return fmt.Errorf("apply trapped or timed out: %w", err) |
| 467 | } |
| 468 | if code := int32(rc[0]); code != 0 { |
| 469 | return fmt.Errorf("apply rejected the update: code %d", code) |
| 470 | } |
| 471 | return nil |
| 472 | }) |
| 473 | } |
| 474 | |
| 475 | // retain records an update this handle applied cleanly, so a later |
| 476 | // rebuild replays it. Copies: the caller may reuse its slice. |
| 477 | func (h *Handle) retain(update []byte) { |
| 478 | stored := make([]byte, len(update)) |
| 479 | copy(stored, update) |
| 480 | h.applied = append(h.applied, stored) |
| 481 | } |
| 482 | |
| 483 | // contain turns an error from a guest call into what Apply and Ops |
| 484 | // return: a trap or timeout discards and rebuilds the instance (a |
| 485 | // *RecoveredError when that works, a joined error when it does not); |
| 486 | // anything else is a *RejectedError, the instance untouched. |
| 487 | func (h *Handle) contain(ctx context.Context, err error) error { |
| 488 | if isFatal(err) { |
| 489 | if rebuildErr := h.rebuild(ctx); rebuildErr != nil { |
| 490 | return errors.Join(err, rebuildErr) |
| 491 | } |
| 492 | return &RecoveredError{err} |
| 493 | } |
| 494 | return &RejectedError{err} |
| 495 | } |
| 496 | |
| 497 | // Apply applies update to the document. A clean rejection (a negative |
| 498 | // error code from the guest, e.g. malformed wire bytes that yrs's decoder |
| 499 | // catches) leaves the instance untouched and fully usable and comes back |
| 500 | // as a *RejectedError (see Rejected). A trap or a deadline exceeded is |
| 501 | // different: the instance is discarded and rebuilt from every update |
| 502 | // applied here so far, and the triggering update is reported as an error |
| 503 | // but never retried or logged. When that rebuild succeeds, the returned |
| 504 | // error is a *RecoveredError (see Recovered); a rebuild that fails returns |
| 505 | // a plain joined error instead — the one shape this method returns that is |
| 506 | // neither, and the one a caller must not mistake for either: the document |
| 507 | // itself may now be unusable, not merely this one update. |
| 508 | // |
| 509 | // Once the document's retained update log is full (maxRetainedUpdates), |
| 510 | // Apply refuses any further update with ErrUpdateLogFull. |
| 511 | func (h *Handle) Apply(ctx context.Context, update []byte) error { |
| 512 | if len(h.applied) >= maxRetainedUpdates { |
| 513 | return ErrUpdateLogFull |
| 514 | } |
| 515 | if err := applyOn(ctx, h.rt.callTimeout, h.mod, h.handle, update); err != nil { |
| 516 | return h.contain(ctx, err) |
| 517 | } |
| 518 | h.retain(update) |
| 519 | return nil |
| 520 | } |
| 521 | |
| 522 | // Ops applies a JSON array of operations (spec §7.2, core/src/ops.rs) in |
| 523 | // one transaction and returns the Yjs update that transaction produced — |
| 524 | // the bytes to persist and broadcast exactly as a browser's own update |
| 525 | // would be. The update is also retained for rebuilds, so the log stays |
| 526 | // complete whichever way a document is edited. |
| 527 | // |
| 528 | // A batch the core refuses applies nothing and comes back as a |
| 529 | // *RejectedError whose message is the core's own (which op, and why). The |
| 530 | // trap and timeout handling is Apply's. |
| 531 | func (h *Handle) Ops(ctx context.Context, ops []byte) ([]byte, error) { |
| 532 | if len(h.applied) >= maxRetainedUpdates { |
| 533 | return nil, ErrUpdateLogFull |
| 534 | } |
| 535 | timeout := h.rt.callTimeout |
| 536 | var update []byte |
| 537 | err := withBuffer(ctx, timeout, h.mod, ops, func(ptr, length uint64) error { |
| 538 | rc, err := callGuest(ctx, timeout, h.mod.ExportedFunction("slides_core_ops"), h.handle, ptr, length) |
| 539 | if err != nil { |
| 540 | return fmt.Errorf("ops trapped or timed out: %w", err) |
| 541 | } |
| 542 | switch code := int32(rc[0]); code { |
| 543 | case 0: |
| 544 | update, err = readOn(ctx, timeout, h.mod, h.handle, "out") |
| 545 | if err != nil { |
| 546 | return fmt.Errorf("reading the ops update: %w", err) |
| 547 | } |
| 548 | return nil |
| 549 | case errOps: |
| 550 | msg, err := readOn(ctx, timeout, h.mod, h.handle, "err") |
| 551 | if err != nil { |
| 552 | return fmt.Errorf("ops refused the batch, and reading its message failed: %w", err) |
| 553 | } |
| 554 | return errors.New(string(msg)) |
| 555 | default: |
| 556 | return fmt.Errorf("ops failed: code %d", code) |
| 557 | } |
| 558 | }) |
| 559 | if err != nil { |
| 560 | return nil, h.contain(ctx, err) |
| 561 | } |
| 562 | h.retain(update) |
| 563 | return update, nil |
| 564 | } |
| 565 | |
| 566 | // errOps is core/src/abi.rs's ERR_OPS: the batch was refused and the |
| 567 | // message is in the err read pair. |
| 568 | const errOps = -6 |
| 569 | |
| 570 | // errShape is core/src/abi.rs's ERR_SHAPE: Check found text outside the |
| 571 | // §2.6 vocabulary and the message is in the err read pair. |
| 572 | const errShape = -7 |
| 573 | |
| 574 | // Check runs the core's accepted-shape check (core/src/check.rs) over the |
| 575 | // document: every slide's notes, every block's text and every table's |
| 576 | // cells must be <paragraph> elements holding text whose formatting |
| 577 | // attributes are the five marks §2.6 defines. It exists because the JSON |
| 578 | // view is a projection that tolerates more than the editor does — |
| 579 | // y-prosemirror deletes a text whose attributes name a mark it lacks — |
| 580 | // so a gate on the view alone lets through an update that costs words |
| 581 | // the moment the editor opens (codex 3). |
| 582 | // |
| 583 | // nil when the document is within the vocabulary. A finding comes back |
| 584 | // as a *RejectedError carrying the core's message verbatim, exactly as |
| 585 | // a refused op batch does: the instance is untouched (the check only |
| 586 | // reads) and the caller decides what the finding means for the update |
| 587 | // that produced it. The trap and timeout handling is Apply's. |
| 588 | func (h *Handle) Check(ctx context.Context) error { |
| 589 | timeout := h.rt.callTimeout |
| 590 | rc, err := callGuest(ctx, timeout, h.mod.ExportedFunction("slides_core_check"), h.handle) |
| 591 | if err != nil { |
| 592 | return h.contain(ctx, fmt.Errorf("check trapped or timed out: %w", err)) |
| 593 | } |
| 594 | switch code := int32(rc[0]); code { |
| 595 | case 0: |
| 596 | return nil |
| 597 | case errShape: |
| 598 | msg, err := readOn(ctx, timeout, h.mod, h.handle, "err") |
| 599 | if err != nil { |
| 600 | return h.contain(ctx, fmt.Errorf("check found a problem, and reading its message failed: %w", err)) |
| 601 | } |
| 602 | return &RejectedError{errors.New(string(msg))} |
| 603 | default: |
| 604 | return h.contain(ctx, fmt.Errorf("check failed: code %d", code)) |
| 605 | } |
| 606 | } |
| 607 | |
| 608 | // State returns the document's entire state as one Yjs update — what a |
| 609 | // caller stores as a snapshot so a later replay can start from it instead |
| 610 | // of from the whole log. Applying it to an empty document reproduces this |
| 611 | // one. |
| 612 | func (h *Handle) State(ctx context.Context) ([]byte, error) { |
| 613 | return h.read(ctx, "state") |
| 614 | } |
| 615 | |
| 616 | // JSON returns the deck's view (core/src/view.rs) — what the drive API |
| 617 | // serves and what the renderer and exporters read. Always a complete JSON |
| 618 | // object for a live handle, never empty. |
| 619 | func (h *Handle) JSON(ctx context.Context) ([]byte, error) { |
| 620 | return h.read(ctx, "json") |
| 621 | } |
| 622 | |
| 623 | // read is the shared body of State and JSON: call the named len/read |
| 624 | // pair, and if the call itself traps or times out, discard the instance, |
| 625 | // rebuild it exactly as Apply does, and retry the read once against the |
| 626 | // fresh one. Retrying rather than reporting an error is deliberate — the |
| 627 | // content is available at that point, and returning nothing plus an error |
| 628 | // would tell the caller the data is gone when it is not. |
| 629 | func (h *Handle) read(ctx context.Context, renderer string) ([]byte, error) { |
| 630 | b, err := readOn(ctx, h.rt.callTimeout, h.mod, h.handle, renderer) |
| 631 | if err == nil { |
| 632 | return b, nil |
| 633 | } |
| 634 | if !isFatal(err) { |
| 635 | return nil, err |
| 636 | } |
| 637 | if rebuildErr := h.rebuild(ctx); rebuildErr != nil { |
| 638 | return nil, errors.Join(err, rebuildErr) |
| 639 | } |
| 640 | b, err = readOn(ctx, h.rt.callTimeout, h.mod, h.handle, renderer) |
| 641 | if err != nil { |
| 642 | return nil, fmt.Errorf("reading %s from the rebuilt instance: %w", renderer, err) |
| 643 | } |
| 644 | return b, nil |
| 645 | } |
| 646 | |
| 647 | // readOn performs one len-then-read dance against slides_core_<name>_len |
| 648 | // and slides_core_<name>_read. name is always one of this package's own |
| 649 | // literals ("state", "json", "out", "err"), never anything derived from a |
| 650 | // request — the export names it builds are a closed set, and a name that |
| 651 | // does not resolve to an exported function surfaces below as a nil |
| 652 | // api.Function. |
| 653 | func readOn(ctx context.Context, timeout time.Duration, mod api.Module, docHandle uint64, name string) ([]byte, error) { |
| 654 | lenFn := mod.ExportedFunction("slides_core_" + name + "_len") |
| 655 | readFn := mod.ExportedFunction("slides_core_" + name + "_read") |
| 656 | if lenFn == nil || readFn == nil { |
| 657 | // Unreachable while abiVersion matches — the version check at |
| 658 | // load is what proves these exports exist — but a nil |
| 659 | // api.Function panics inside callGuest rather than erroring, so |
| 660 | // this turns a future ABI mistake into a returned error instead |
| 661 | // of a panic in a request handler. |
| 662 | return nil, fmt.Errorf("module exports no %q read pair", name) |
| 663 | } |
| 664 | |
| 665 | lenRes, err := callGuest(ctx, timeout, lenFn, docHandle) |
| 666 | if err != nil { |
| 667 | return nil, fmt.Errorf("%s_len trapped or timed out: %w", name, err) |
| 668 | } |
| 669 | // The len functions return i32: negative is an error code, not a |
| 670 | // length. Reading it as unsigned would turn ERR_NO_DOC into |
| 671 | // 0xFFFFFFFF and then try to allocate four gigabytes. |
| 672 | if code := int32(lenRes[0]); code < 0 { |
| 673 | return nil, fmt.Errorf("%s_len failed: code %d", name, code) |
| 674 | } |
| 675 | n := lenRes[0] |
| 676 | if n == 0 { |
| 677 | return nil, nil |
| 678 | } |
| 679 | |
| 680 | ptr, err := allocIn(ctx, timeout, mod, n) |
| 681 | if err != nil { |
| 682 | return nil, fmt.Errorf("alloc: %w", err) |
| 683 | } |
| 684 | defer freeIn(ctx, timeout, mod, ptr, n) |
| 685 | |
| 686 | rc, err := callGuest(ctx, timeout, readFn, docHandle, ptr, n) |
| 687 | if err != nil { |
| 688 | return nil, fmt.Errorf("%s_read trapped or timed out: %w", name, err) |
| 689 | } |
| 690 | written := int32(rc[0]) |
| 691 | if written < 0 { |
| 692 | return nil, fmt.Errorf("%s_read failed: code %d", name, written) |
| 693 | } |
| 694 | |
| 695 | // The read functions return the byte count they wrote, guaranteed |
| 696 | // only <= the len call's answer. Read exactly what the read call says |
| 697 | // it wrote, never the buffer size, so the two can never disagree into |
| 698 | // trailing uninitialised bytes. |
| 699 | buf, ok := mod.Memory().Read(uint32(ptr), uint32(written)) |
| 700 | if !ok { |
| 701 | return nil, fmt.Errorf("reading %d bytes at %d: out of range", written, ptr) |
| 702 | } |
| 703 | // Copy: buf aliases the module's linear memory, which the deferred |
| 704 | // free and any later guest call may reuse. |
| 705 | out := make([]byte, len(buf)) |
| 706 | copy(out, buf) |
| 707 | return out, nil |
| 708 | } |
| 709 | |