Skip to main content

lattice_syntax/
handle.rs

1//! `SyntaxHandle` -- the async wrapper around per-document
2//! [`Syntax`] that runs reparses off the UI thread.
3//!
4//! ## Why this exists
5//!
6//! The audit's C1 finding: `Syntax::parse` runs synchronously on
7//! whatever thread calls it, and the App was calling it from
8//! `App::apply` (every `Action`) and `App::refresh_highlights`
9//! (per frame). On a multi-MB buffer that's a sub-millisecond
10//! to multi-millisecond stall on the UI thread -- a direct
11//! violation of paramount goal #1 ("UI thread does no … parsing").
12//!
13//! ## Architecture
14//!
15//! Mirrors the patterns we use for the document actor and the
16//! LSP supervisor:
17//!
18//! - **Worker task.** Owns the `Syntax` instance + `Parser`.
19//!   Receives `(from_version, text_version, buffer, edits)`
20//!   reparse requests on an unbounded mpsc channel. Each request
21//!   runs the parse on `tokio::task::spawn_blocking` so the
22//!   long-running tree-sitter call doesn't tie up a worker
23//!   thread for the whole runtime; on completion the worker
24//!   stores a fresh [`SyntaxSnapshot`] in the handle's `ArcSwap`
25//!   cell.
26//! - **Buffer-not-text** (slice B.5). The request carries a
27//!   `Buffer` (O(1) Arc-bump clone via ropey's internal sharing)
28//!   instead of a pre-materialized `String`. The worker calls
29//!   `buffer.as_string()` on its `spawn_blocking` thread, so the
30//!   O(n) source materialization stays off the input thread per
31//!   paramount goal #1.
32//! - **Incremental reparse with intermediate publish** (slices
33//!   B.2 + C.2). Non-empty `edits` route to
34//!   `Syntax::try_apply_intermediate` first -- this applies
35//!   `tree.edit()` per delta and updates the cached source +
36//!   `text_version`, but does NOT yet run `Parser::parse`. The
37//!   worker then publishes an intermediate `ArcSwap::store` of
38//!   this byte-shifted-but-pre-parse-shape snapshot, so renderers
39//!   immediately see byte-aligned spans for unchanged content
40//!   (only the changed region's tree shape is briefly stale).
41//!   THEN `reparse_with_cached_tree` runs `Parser::parse(_,
42//!   Some(&old_tree))` which reuses unchanged subtrees, and
43//!   the worker publishes the final snapshot. Empty edits or
44//!   any guard violation in `try_apply_intermediate` falls
45//!   through to full reparse with a single publish.
46//! - **Coalescing.** When multiple requests are queued, the
47//!   worker accumulates `edits` in arrival order, takes the
48//!   latest `buffer` and `text_version`, keeps the earliest
49//!   `from_version`. Preserves edit ordering across the burst;
50//!   the burst maps to a single `Parser::parse`. Coalesced edit
51//!   count is capped at `MAX_INCREMENTAL_EDITS_PER_REQUEST`
52//!   (256) to bound worst-case `tree.edit()` overhead;
53//!   pathological bursts fall through to full reparse.
54//! - **Wait-free reads.** The App / renderer / fold provider
55//!   reads the latest snapshot via `handle.snapshot()` (one
56//!   `ArcSwap::load_full`). No mutex, no actor round-trip.
57//!
58//! ## Test path
59//!
60//! For sync tests that pre-build a parsed `Syntax` and want to
61//! plug it into the handle directly, [`SyntaxHandle::seeded`]
62//! constructs a handle whose snapshot starts populated and
63//! whose worker task either runs (if a tokio runtime is
64//! present) or is skipped (if not -- read-only handle).
65
66use std::sync::Arc;
67
68use arc_swap::ArcSwap;
69use tokio::sync::mpsc;
70
71use lattice_core::Buffer;
72use lattice_protocol::edit::EditDelta;
73
74use crate::lang::Lang;
75use crate::registry::LangRegistry;
76use crate::syntax::{Syntax, SyntaxError, SyntaxSnapshot};
77
78/// Cap on the per-request edit count. Bounds worst-case
79/// `tree.edit()` cost in the worker before parse: at ~500ns per
80/// edit, 256 edits = ~128µs of pre-parse work, comparable to the
81/// parse itself. Beyond that, the bookkeeping cost exceeds the
82/// incremental win, so we drop the edits and force a full
83/// reparse. Pathological case: a 100k-char paste delivered as
84/// per-character edits (shouldn't happen via Action paths, but
85/// belt-and-suspenders).
86pub(crate) const MAX_INCREMENTAL_EDITS_PER_REQUEST: usize = 256;
87
88/// Editor-facing handle. Cheap to clone; cloning gives a
89/// reference to the same underlying snapshot cell + the same
90/// reparse-request channel.
91#[derive(Clone)]
92pub struct SyntaxHandle {
93    snapshot: Arc<ArcSwap<SyntaxSnapshot>>,
94    cmd_tx: mpsc::UnboundedSender<ReparseRequest>,
95}
96
97struct ReparseRequest {
98    /// Version the worker's tree is expected to be at BEFORE
99    /// applying `edits`. The worker compares this against its
100    /// own `tree.text_version` and falls back to full reparse on
101    /// mismatch (see [`Syntax::parse_at_with_edits`] guards).
102    from_version: u64,
103    /// Version the resulting snapshot should be stamped with
104    /// AFTER the parse completes.
105    text_version: u64,
106    /// Buffer at `text_version`. Slice B.5: carries the rope
107    /// (cloning is O(1) via ropey's internal Arc) instead of a
108    /// pre-materialized `String`. The worker materializes the
109    /// rope to bytes via `buffer.as_string()` on the worker
110    /// thread immediately before parse, moving the O(n) alloc
111    /// off the input thread per paramount goal #1 ("UI thread
112    /// does no I/O, no parsing"). Input-thread cost goes from
113    /// O(n) String alloc + memcpy down to O(1) Arc bump (~ns).
114    buffer: Buffer,
115    /// Edit deltas in apply-order, taking the buffer from
116    /// `from_version` to `text_version`. Empty = "no deltas
117    /// available, do full reparse" (file load, document replace).
118    edits: Vec<EditDelta>,
119}
120
121impl SyntaxHandle {
122    /// Build a handle for `lang` and start the worker task.
123    /// Returns `Ok(None)` for `Lang::Plain` or any language not
124    /// registered in the supplied registry -- the App treats
125    /// `None` as "no syntax highlighting for this buffer".
126    pub fn spawn(lang: Lang, registry: Arc<LangRegistry>) -> Result<Option<Self>, SyntaxError> {
127        let Some(syntax) = Syntax::for_language_with_registry(lang, registry)? else {
128            return Ok(None);
129        };
130        Ok(Some(Self::seeded(syntax)))
131    }
132
133    /// Wrap a pre-built `Syntax` (already parsed or not) in a
134    /// handle. Used by tests that want to drive parses
135    /// synchronously and then read the result, and by callers
136    /// that already constructed a `Syntax` for other reasons
137    /// (one-shot help-buffer markdown highlighting).
138    ///
139    /// **Production callers must use [`Self::seeded_with_runtime`]
140    /// instead.** This method falls back to
141    /// `Handle::try_current()` which silently fails when the
142    /// caller hasn't entered a tokio context (notably: editor
143    /// startup runs from a synchronous `main()`, before the
144    /// runtime is set up). When that fails, the worker is never
145    /// spawned and `request_reparse` calls go to a dropped
146    /// channel -- the snapshot stays at the seeded state forever
147    /// and no reparses ever run. LSP solved the same problem
148    /// with an explicit runtime handle (see app.rs:96-100); this
149    /// constructor remains for tests that already run inside a
150    /// tokio context.
151    pub fn seeded(syntax: Syntax) -> Self {
152        let snapshot = Arc::new(ArcSwap::from_pointee(syntax.snapshot_owned()));
153        let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<ReparseRequest>();
154        let snapshot_for_task = snapshot.clone();
155        match tokio::runtime::Handle::try_current() {
156            Ok(handle) => {
157                handle.spawn(worker_main(syntax, cmd_rx, snapshot_for_task, None));
158            }
159            Err(_) => {
160                drop(cmd_rx);
161                drop(syntax);
162            }
163        }
164        Self { snapshot, cmd_tx }
165    }
166
167    /// Wrap a pre-built `Syntax` and spawn the worker on the
168    /// supplied tokio runtime handle. **This is the production
169    /// constructor.** Mirrors what
170    /// `lattice_lsp::supervisor::LspSupervisor::spawn` does: take
171    /// an explicit handle so the worker actually starts even when
172    /// the caller hasn't entered a tokio context yet (editor
173    /// startup runs from synchronous `main()` and constructs the
174    /// handle before the main loop enters tokio).
175    ///
176    /// Without this, `Handle::try_current()` in [`Self::seeded`]
177    /// silently fails, the worker is never spawned, and Option B's
178    /// entire incremental-reparse pipeline is dead -- `request_reparse`
179    /// sends to a dropped channel, the snapshot stays at the seeded
180    /// state, and no edit ever produces a fresh tree. The user-visible
181    /// symptom is "syntax highlighting is stuck to byte positions and
182    /// never tracks document edits" -- which was the bug originally
183    /// reported against indent (`>>`) and backspace flows.
184    /// `on_publish`, when `Some`, is called after every snapshot publish
185    /// (both the intermediate edit-shift and the final reparse).
186    /// Production passes a closure that fires the host's `async_landed`
187    /// Notify (waking the actor) and publishes a `SyntaxReparsed` event
188    /// on the event bus; the event subscriber then fires `cells_wake` so
189    /// idle reparses repaint without a keystroke. Tests pass `None`.
190    pub fn seeded_with_runtime(
191        syntax: Syntax,
192        runtime: &tokio::runtime::Handle,
193        on_publish: Option<Arc<dyn Fn() + Send + Sync + 'static>>,
194    ) -> Self {
195        let snapshot = Arc::new(ArcSwap::from_pointee(syntax.snapshot_owned()));
196        let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<ReparseRequest>();
197        let snapshot_for_task = snapshot.clone();
198        runtime.spawn(worker_main(syntax, cmd_rx, snapshot_for_task, on_publish));
199        Self { snapshot, cmd_tx }
200    }
201
202    /// Wait-free read of the latest parse snapshot. Hot path;
203    /// renderer / fold provider / completion call this on every
204    /// frame.
205    pub fn snapshot(&self) -> Arc<SyntaxSnapshot> {
206        self.snapshot.load_full()
207    }
208
209    /// Borrow the snapshot via `ArcSwap`'s short-lived guard.
210    /// Cheaper than [`Self::snapshot`] when the caller only
211    /// needs to read a single field (no `Arc::clone`).
212    pub fn with_snapshot<R>(&self, f: impl FnOnce(&SyntaxSnapshot) -> R) -> R {
213        let guard = self.snapshot.load();
214        f(&guard)
215    }
216
217    /// Convenience: latest `Lang`. Wait-free.
218    pub fn lang(&self) -> Lang {
219        self.snapshot.load().lang()
220    }
221
222    /// Request that the worker reparse `buffer` and stamp the
223    /// resulting snapshot with `text_version`. Fire-and-forget;
224    /// the new snapshot is observable via [`Self::snapshot`]
225    /// once the worker completes the parse.
226    ///
227    /// Slice B.5: takes `Buffer` (clones in O(1) via ropey's
228    /// internal Arc) rather than `String`. The full source
229    /// materialization moves to the worker, off the input
230    /// thread.
231    ///
232    /// `from_version` is the version-baseline the worker's tree
233    /// is expected to be at BEFORE applying `edits`. The worker
234    /// uses it to detect mismatches (dropped requests, file
235    /// load, document replace) and falls back to full reparse
236    /// when the cached tree's version doesn't match.
237    ///
238    /// `edits` carries the tree-sitter-shaped deltas in apply
239    /// order, taking the buffer from `from_version` to
240    /// `text_version`. Empty `edits` = "do a full reparse"
241    /// (file load / cold-start path).
242    ///
243    /// Coalesced: queued requests' edits are accumulated in
244    /// order; the latest queued request's `buffer` and
245    /// `text_version` win; the earliest queued request's
246    /// `from_version` survives so the burst's baseline is
247    /// preserved.
248    pub fn request_reparse(
249        &self,
250        from_version: u64,
251        text_version: u64,
252        buffer: Buffer,
253        edits: Vec<EditDelta>,
254    ) {
255        let _ = self.cmd_tx.send(ReparseRequest {
256            from_version,
257            text_version,
258            buffer,
259            edits,
260        });
261    }
262}
263
264impl std::fmt::Debug for SyntaxHandle {
265    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
266        let snap = self.snapshot.load();
267        f.debug_struct("SyntaxHandle")
268            .field("lang", &snap.lang())
269            .field("text_version", &snap.text_version())
270            .finish_non_exhaustive()
271    }
272}
273
274// K.4.7 (2026-06-08): `ExcerptHighlighter` impl so `MultibufferDocumentHandle`
275// can return `Arc<dyn ExcerptHighlighter>` without exposing `SyntaxHandle`
276// to `lattice-runtime` (which would create a dep cycle).
277impl lattice_cells::ExcerptHighlighter for SyntaxHandle {
278    fn highlight_lines(&self, lo: u32, hi: u32) -> Option<Vec<Vec<lattice_cells::StyledSpan>>> {
279        self.snapshot().highlight_lines(lo, hi).ok()
280    }
281
282    fn highlight_version(&self) -> u64 {
283        // `render_version`, NOT `text_version`: one edit publishes twice
284        // (byte-shifted intermediate, then completed reparse) and both carry
285        // the same `text_version`, so a cache keyed on it never rebuilds with
286        // colour. See `SyntaxSnapshot::render_version`.
287        self.snapshot().render_version()
288    }
289}
290
291/// Test-only: make the next parse inside the worker panic.
292///
293/// A tree-sitter panic cannot be induced on demand from outside, and the
294/// property it guards — the worker SURVIVES one and keeps serving later
295/// requests — is the difference between one stale frame and a buffer whose
296/// highlighting is dead until it is reopened. That is worth a hook.
297#[cfg(test)]
298pub(crate) static PANIC_NEXT_PARSE: std::sync::atomic::AtomicBool =
299    std::sync::atomic::AtomicBool::new(false);
300
301/// Worker loop. Owns the `Syntax` exclusively; processes
302/// reparse requests in FIFO order, coalescing newer requests on
303/// top of older ones before running each parse on a blocking
304/// pool thread. Exits when every handle is dropped (sender
305/// closes the channel).
306///
307/// Coalescing semantics (slice B.2): when multiple requests
308/// arrive while the worker is busy, edits accumulate in arrival
309/// order while `text` and `text_version` snap to the latest, and
310/// `from_version` snaps to the earliest. Result: a single parse
311/// applies the union of edits in order, taking the cached tree
312/// from the burst's baseline to the burst's tip in one step.
313/// Dropping older requests in favour of the latest (the pre-B.2
314/// behaviour) would lose their edits and silently corrupt the
315/// cached tree's byte ranges -- the new shape preserves them.
316///
317/// Edit count is capped at [`MAX_INCREMENTAL_EDITS_PER_REQUEST`].
318/// Beyond that, the worker drops the edits and lets the syntax's
319/// full-reparse fallback fire (still publishes a correct tree,
320/// just at full-reparse cost).
321async fn worker_main(
322    mut syntax: Syntax,
323    mut cmd_rx: mpsc::UnboundedReceiver<ReparseRequest>,
324    snapshot: Arc<ArcSwap<SyntaxSnapshot>>,
325    on_publish: Option<Arc<dyn Fn() + Send + Sync + 'static>>,
326) {
327    while let Some(mut req) = cmd_rx.recv().await {
328        // Coalesce queued requests: accumulate edits in order,
329        // take latest buffer/text_version, keep earliest
330        // from_version. The mailbox is FIFO so this preserves
331        // edit ordering across the burst.
332        let mut acc_edits = std::mem::take(&mut req.edits);
333        while let Ok(mut next) = cmd_rx.try_recv() {
334            if next.text_version >= req.text_version {
335                acc_edits.append(&mut next.edits);
336                req.buffer = next.buffer;
337                req.text_version = next.text_version;
338            }
339        }
340        req.edits = acc_edits;
341
342        // Pathological-burst guard: if the accumulated edit list
343        // exceeds the cap, drop the edits and let the syntax's
344        // empty-edits guard route us to a full reparse. Cheaper
345        // than applying ~thousands of `tree.edit()` calls before
346        // the parse.
347        if req.edits.len() > MAX_INCREMENTAL_EDITS_PER_REQUEST {
348            req.edits.clear();
349        }
350
351        // Run the parse on a blocking thread; tree-sitter
352        // parses can take ~ms on large buffers and we don't
353        // want to tie up a tokio worker thread. The closure
354        // moves `syntax` in and back out so we keep ownership.
355        let ReparseRequest {
356            from_version,
357            text_version,
358            buffer,
359            edits,
360        } = req;
361        let snapshot_for_intermediate = snapshot.clone();
362        let parsed = tokio::task::spawn_blocking(move || {
363            // The parse is wrapped so a panic inside tree-sitter cannot take
364            // `syntax` with it: the closure hands ownership back either way and
365            // the caller decides what to do. See the `Err` arm below for why
366            // that matters more than it looks.
367            let mut panicked = false;
368            // Slice B.5: materialize the source bytes on the
369            // worker thread, not the input thread. `as_string`
370            // is O(n) for the rope but happens here on the
371            // spawn_blocking pool.
372            let text = buffer.as_string();
373            // Slice C.2: two-stage parse with intermediate
374            // publish.
375            //
376            // Stage 1 (fast, ~µs): try_apply_intermediate runs
377            // tree.edit() per delta -- shifts every node's byte
378            // range to track the edits -- and updates the
379            // snapshot's source + text_version. The result is
380            // byte-aligned (all node ranges match the new
381            // source) but tree shape is pre-parse for the
382            // changed regions.
383            //
384            // Publishing the intermediate now means the renderer
385            // sees byte-aligned spans for the entire parse
386            // window. Lines below a deleted line, lines after a
387            // multi-byte insert, etc. all paint at correct
388            // positions immediately -- no flicker for unchanged
389            // content. Only the changed region's tree shape is
390            // briefly stale (which usually doesn't affect
391            // colour: shape-staleness within a string node, an
392            // identifier node, etc. produces the same span as
393            // the post-parse shape).
394            //
395            // Stage 2 (slow, ~50-300µs): reparse_with_cached_tree
396            // runs Parser::parse with the (already edited) tree
397            // as seed. tree-sitter reuses unchanged subtrees;
398            // only the edited region gets re-parsed. The final
399            // publish lands a moment later.
400            //
401            // On guard failure (no cached tree, version mismatch,
402            // byte-length mismatch), Stage 1 returns Err, and we
403            // fall through to a full reparse with a single
404            // publish.
405            let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
406                #[cfg(test)]
407                if PANIC_NEXT_PARSE.swap(false, std::sync::atomic::Ordering::SeqCst) {
408                    panic!("injected parse panic");
409                }
410                let intermediate_ok = !edits.is_empty()
411                    && syntax
412                        .try_apply_intermediate(&text, text_version, from_version, &edits)
413                        .is_ok();
414                if intermediate_ok {
415                    snapshot_for_intermediate.store(Arc::new(syntax.snapshot_owned()));
416                    syntax.reparse_with_cached_tree(from_version);
417                } else {
418                    syntax.parse_at(&text, text_version);
419                }
420            }));
421            if outcome.is_err() {
422                panicked = true;
423            }
424            (syntax, panicked)
425        })
426        .await;
427        let (next, panicked) = match parsed {
428            Ok(pair) => pair,
429            Err(join_err) => {
430                // The task could not be joined at all — cancelled, or the
431                // runtime is shutting down. Nothing left to own, so the worker
432                // does have to stop here; but it says so first, because the
433                // symptom on screen (highlighting frozen forever, redraw does
434                // not help, reopening the file fixes it) says nothing about
435                // which layer died.
436                tracing::error!(
437                    target: "lattice_host::syntax",
438                    error = %join_err,
439                    text_version,
440                    "syntax_reparse_worker_stopped"
441                );
442                return;
443            }
444        };
445        if panicked {
446            // A PANIC IN THE PARSE IS NO LONGER FATAL TO THE WORKER.
447            //
448            // It used to be: the panic destroyed `syntax`, the worker
449            // `return`ed, and every later request went into a channel with no
450            // receiver. The snapshot froze at the last good parse for the life
451            // of that buffer — `<C-l>` cannot help, because a redraw rebuilds
452            // from a snapshot that is never going to change again, and only
453            // reopening the file (a NEW handle, a NEW worker) recovered.
454            //
455            // It also died SILENTLY, which is why the pair of
456            // `syntax_reparse_requested` / `syntax_reparse_published` lines
457            // pointed at the worker and then stopped: nothing recorded the
458            // reason. Now the panic is caught inside the closure, `syntax`
459            // comes back, and the worker keeps serving the next request — so a
460            // parse that fails while a grammar is mid-rebuild costs one stale
461            // frame instead of a permanently dead buffer.
462            //
463            // Skipping the publish is deliberate: the tree is in whatever state
464            // the panic left it, and painting from it would be worse than
465            // painting from the last known-good snapshot.
466            tracing::error!(
467                target: "lattice_host::syntax",
468                from_version,
469                text_version,
470                "syntax_reparse_panicked"
471            );
472            syntax = next;
473            continue;
474        }
475        snapshot.store(Arc::new(next.snapshot_owned()));
476        syntax = next;
477        // The far end of `syntax_reparse_requested`. A request logged with no
478        // publish logged after it means the parse never completed — the
479        // blocking task panicked and the `return` above took the worker down
480        // with it, after which every later request sends into a channel with
481        // no receiver and the snapshot is frozen for good. That is a
482        // never-self-heals shape, and it is indistinguishable on screen from
483        // a stale display matrix; these two lines together are what tell them
484        // apart. One line per reparse, not per frame.
485        tracing::debug!(
486            target: "lattice_host::syntax",
487            text_version = snapshot.load().text_version(),
488            "syntax_reparse_published"
489        );
490        // 2026-06-03 (slice B.1): wake the host so the editor actor
491        // re-publishes render state now that fresh syntax is
492        // available — without this, an idle reparse (no keystroke in
493        // flight) wouldn't repaint until the next key. Reached by both
494        // the incremental and full-reparse paths (single fire point).
495        // The intermediate publish above already advanced the snapshot
496        // version, so one wake here suffices.
497        if let Some(cb) = on_publish.as_ref() {
498            cb();
499        }
500    }
501}
502
503#[cfg(test)]
504mod tests {
505    use super::*;
506    use crate::lang::Lang;
507    use crate::syntax::Syntax;
508    use std::time::Duration;
509    use tokio::sync::Notify;
510
511    /// Serialise the worker tests: [`PANIC_NEXT_PARSE`] is PROCESS-GLOBAL.
512    ///
513    /// Run concurrently, one test's armed panic is consumed by the other's
514    /// parse — the panic lands in the wrong worker and both fail, which is
515    /// exactly what happened the first time this test was written. A global
516    /// injection point needs a global lock; making the tests look independent
517    /// when they share a static is how a suite earns a reputation for
518    /// flakiness. `tokio::sync::Mutex` because the critical section spans
519    /// `.await`.
520    static WORKER_TESTS: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
521
522    /// Slice B.1 (2026-06-03): a reparse publish must fire the
523    /// `on_publish` wake so the editor actor can re-publish render
524    /// state on idle reparse completion. Without this, an idle reparse
525    /// (no keystroke in flight) never repaints — the markdown
526    /// "highlighting never comes back" symptom's idle half.
527
528    /// A panicking parse must NOT kill the worker.
529    ///
530    /// It used to: the panic destroyed the `Syntax`, the worker returned, and
531    /// every later request went into a channel with no receiver. The snapshot
532    /// froze at the last good parse for the life of that buffer — `<C-l>` could
533    /// not help, because a redraw rebuilds from a snapshot that will never
534    /// change again, and only reopening the file recovered.
535    ///
536    /// It died silently too, which is why a debug log showed
537    /// `syntax_reparse_requested` lines with no `syntax_reparse_published`
538    /// after them and nothing saying why.
539    ///
540    /// The second request is the whole test: one bad parse costs a stale frame,
541    /// not the buffer.
542    #[tokio::test]
543    async fn a_panicking_parse_does_not_kill_the_worker() {
544        let _serialised = WORKER_TESTS.lock().await;
545        let mut s = Syntax::for_language(Lang::Rust).unwrap().unwrap();
546        s.parse_at("fn main() {}\n", 1);
547        let wake = Arc::new(Notify::new());
548        let wake_cb = wake.clone();
549        let handle = SyntaxHandle::seeded_with_runtime(
550            s,
551            &tokio::runtime::Handle::current(),
552            Some(Arc::new(move || wake_cb.notify_one())),
553        );
554
555        // Arm the panic, then ask for a reparse. It must publish nothing.
556        PANIC_NEXT_PARSE.store(true, std::sync::atomic::Ordering::SeqCst);
557        handle.request_reparse(
558            1,
559            2,
560            Buffer::from_text("fn main() {}\n// two\n"),
561            Vec::<EditDelta>::new(),
562        );
563        assert!(
564            tokio::time::timeout(Duration::from_millis(500), wake.notified())
565                .await
566                .is_err(),
567            "a panicking parse must not publish a half-built tree"
568        );
569
570        // The worker is still alive: the NEXT request publishes normally.
571        handle.request_reparse(
572            1,
573            3,
574            Buffer::from_text("fn main() {}\n// three\n"),
575            Vec::<EditDelta>::new(),
576        );
577        tokio::time::timeout(Duration::from_secs(2), wake.notified())
578            .await
579            .expect("the worker survived the panic and published the next parse");
580        assert_eq!(
581            handle.snapshot().text_version(),
582            3,
583            "and the snapshot advanced to the version that parsed cleanly"
584        );
585    }
586
587    #[tokio::test]
588    async fn reparse_publish_fires_on_publish_wake() {
589        let _serialised = WORKER_TESTS.lock().await;
590        let mut s = Syntax::for_language(Lang::Rust).unwrap().unwrap();
591        s.parse_at("fn main() {}\n", 1);
592        let wake = Arc::new(Notify::new());
593        let wake_cb = wake.clone();
594        let handle = SyntaxHandle::seeded_with_runtime(
595            s,
596            &tokio::runtime::Handle::current(),
597            Some(Arc::new(move || wake_cb.notify_one())),
598        );
599
600        // Full reparse (empty edits) to version 2.
601        handle.request_reparse(
602            1,
603            2,
604            Buffer::from_text("fn main() {}\n// new\n"),
605            Vec::<EditDelta>::new(),
606        );
607
608        // The worker must fire the wake after publishing.
609        tokio::time::timeout(Duration::from_secs(2), wake.notified())
610            .await
611            .expect("on_publish wake must fire after a reparse publish");
612        assert_eq!(
613            handle.snapshot().text_version(),
614            2,
615            "snapshot must reflect the reparsed version once the wake fires"
616        );
617    }
618}