oficina / slides Public

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

Plain git — no account needed to clone.

Download

Download this file

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.
40package core
41
42import (
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
54var 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).
62const 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.
67const 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.
74const 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.
85const maxRetainedUpdates = 4096
86
87// MaxRetainedUpdates is maxRetainedUpdates, exported for callers outside
88// this package that need the same cap.
89const 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.
94var 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.
111const defaultMemoryPages = 4096
112
113// Option configures a Runtime. See WithGuestCallTimeout.
114type Option func(*runtimeOptions)
115
116type 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.
125func 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.
132func 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).
139type 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
149func 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
178func (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.
183func (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.
215func 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.
230func 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.
249type trapFault struct{ err error }
250
251func (t *trapFault) Error() string { return t.err.Error() }
252func (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.
258func 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.
272type RecoveredError struct{ err error }
273
274func (e *RecoveredError) Error() string { return e.err.Error() }
275func (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.
280func 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.
294type RejectedError struct{ err error }
295
296func (e *RejectedError) Error() string { return e.err.Error() }
297func (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.
301func 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.
315type 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.
330func (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.
349func 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).
373func 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.
388func 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
392func (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.
413func (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.
446func 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.
462func 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.
477func (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.
487func (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.
511func (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.
531func (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.
568const 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.
572const 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.
588func (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.
612func (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.
619func (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.
629func (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.
653func 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