Skip to main content

webui_handler/streaming/
session.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Public borrowed session API and shared owned continuation state.
5
6use std::borrow::Cow;
7use std::collections::{HashMap, HashSet};
8use std::sync::Arc;
9
10use serde_json::{Number, Value};
11
12use super::error::{boundary_order_error, state_update_type_error};
13use super::state::{
14    increment_streaming_record_sequence, overlay_full_state, overlay_selected_state,
15    selected_state_snapshot, StreamingProgress, StreamingRenderState,
16};
17use super::vm::{ContinuationVm, StepGoal};
18use super::StreamingSink;
19use crate::plugin::HandlerPlugin;
20use crate::route_handler::Protocol;
21use crate::{
22    FlushWriter, HandlerError, RenderOptions, ResponseWriter, Result, WebUIHandler,
23    WebUIProcessContext,
24};
25
26/// Maximum continuation frames retained by one response.
27pub const MAX_CONTINUATION_DEPTH: usize = 256;
28/// Maximum nested unfinished component spans.
29pub const MAX_SPAN_NESTING: usize = 32;
30/// Maximum runtime boundary occurrences in one response.
31pub const MAX_BOUNDARY_OCCURRENCES: usize = 512;
32/// Maximum runtime boundary occurrences one response may commit as
33/// [`BoundaryMode::Updatable`].
34///
35/// Mirrors the browser coordinator's retained-boundary cap: the client refuses
36/// to retain a 129th updatable occurrence, so the server refuses to emit one
37/// rather than stream a checkpoint the page would fail on.
38pub(crate) const MAX_UPDATABLE_OCCURRENCES: usize = 128;
39/// Maximum keyed runtime occurrences tracked for uniqueness.
40pub const MAX_KEYED_INSTANCES: usize = 512;
41/// Maximum top-level state keys retained by a continuation snapshot.
42pub(crate) const MAX_FROZEN_STATE_KEYS: usize = 1_024;
43
44/// Response-local runtime boundary occurrence identity.
45#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
46pub struct BoundaryInstanceId(u32);
47
48impl BoundaryInstanceId {
49    /// Rebuild an ID round-tripped through a host binding.
50    #[must_use]
51    pub const fn from_raw(raw: u32) -> Self {
52        Self(raw)
53    }
54
55    /// Return the wire integer.
56    #[must_use]
57    pub const fn raw(self) -> u32 {
58        self.0
59    }
60
61    pub(crate) fn index(self) -> Result<usize> {
62        usize::try_from(self.0).map_err(|_| {
63            HandlerError::Invariant("boundary instance ID does not fit usize".to_string())
64        })
65    }
66}
67
68/// Response-local generated component span identity.
69#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
70pub struct SpanInstanceId(u32);
71
72impl SpanInstanceId {
73    /// Rebuild an ID round-tripped through a host binding.
74    #[must_use]
75    pub const fn from_raw(raw: u32) -> Self {
76        Self(raw)
77    }
78
79    /// Return the wire integer.
80    #[must_use]
81    pub const fn raw(self) -> u32 {
82        self.0
83    }
84
85    pub(crate) const fn new(raw: u32) -> Self {
86        Self(raw)
87    }
88}
89
90/// Valid evaluated key for a repeated boundary declaration.
91#[derive(Clone, Debug, Eq, Hash, PartialEq)]
92pub enum BoundaryKey {
93    /// Authored key resolved to a JSON string.
94    String(String),
95    /// Authored key resolved to a finite JSON number.
96    Number(Number),
97}
98
99impl BoundaryKey {
100    pub(crate) fn diagnostic(&self) -> String {
101        match self {
102            Self::String(value) => {
103                let mut out = String::with_capacity(value.len() + 2);
104                out.push('"');
105                out.push_str(value);
106                out.push('"');
107                out
108            }
109            Self::Number(value) => value.to_string(),
110        }
111    }
112}
113
114/// Runtime occurrence returned when traversal suspends.
115#[derive(Clone, Debug, Eq, PartialEq)]
116pub struct BoundaryDescriptor {
117    /// Gapless response-local occurrence ID.
118    pub instance_id: BoundaryInstanceId,
119    /// Stable compiler declaration ID.
120    pub declaration_id: u32,
121    /// Entry or component template that owns the declaration.
122    ///
123    /// Interned per protocol, so producing a descriptor shares the compiled
124    /// string instead of allocating a copy per occurrence.
125    pub owner: Arc<str>,
126    /// Free-form authored declaration name.
127    ///
128    /// Interned per protocol alongside [`Self::owner`].
129    pub name: Arc<str>,
130    /// Evaluated repeat key, when authored.
131    pub key: Option<BoundaryKey>,
132}
133
134/// Whether a committed occurrence may receive later state updates.
135#[derive(Clone, Copy, Debug, Eq, PartialEq)]
136pub enum BoundaryMode {
137    /// Hydrate once and release boundary-local roots.
138    Final,
139    /// Retain live roots until terminal for later [`StreamingResponse::update`].
140    Updatable,
141}
142
143/// Borrowed-writer result of one semantic streaming step.
144///
145/// The pair of fields names exactly one state:
146///
147/// | `boundary` | `done` | meaning |
148/// |------------|--------|---------|
149/// | `Some`     | `false`| the occurrence is waiting for [`StreamingResponse::resume`] |
150/// | `None`     | `false`| the committed occurrence flushed; call [`StreamingResponse::advance`] |
151/// | `None`     | `true` | the terminal record and writer end completed |
152#[derive(Clone, Debug, Eq, PartialEq)]
153pub struct StreamStatus {
154    /// The next occurrence waiting for [`StreamingResponse::resume`].
155    pub boundary: Option<BoundaryDescriptor>,
156    /// True after the terminal record and writer end completed.
157    pub done: bool,
158}
159
160/// A progressive response that writes directly through a [`FlushWriter`].
161///
162/// Steps alternate: [`Self::start`] writes the shell prefix and stops before
163/// the first occurrence, [`Self::resume`] writes exactly one occurrence through
164/// its checkpoint, and [`Self::advance`] writes the ordinary parent bytes that
165/// follow it until the next occurrence or the terminal. Every step is one
166/// independently writable, independently flushed segment.
167pub struct StreamingResponse<'a, W: FlushWriter + ?Sized> {
168    handler: &'a WebUIHandler,
169    protocol: &'a Protocol,
170    options: RenderOptions<'a>,
171    sink: StreamingSink<'a, W>,
172    pub(crate) core: SessionCore,
173}
174
175impl WebUIHandler {
176    /// Create a runtime-discovered progressive response.
177    pub fn stream_response<'a, W: FlushWriter + ?Sized>(
178        &'a self,
179        protocol: &'a Protocol,
180        options: &RenderOptions<'a>,
181        writer: &'a mut W,
182    ) -> Result<StreamingResponse<'a, W>> {
183        protocol.ensure_style_metadata()?;
184        let core = SessionCore::new(self, protocol, options.entry_id)?;
185        Ok(StreamingResponse {
186            handler: self,
187            protocol,
188            options: RenderOptions {
189                entry_id: options.entry_id,
190                request_path: options.request_path,
191                nonce: options.nonce,
192                head_inject: options.head_inject,
193                body_inject: options.body_inject,
194            },
195            sink: StreamingSink {
196                transport: writer,
197                component_opening: None,
198                written: 0,
199                flushed: 0,
200            },
201            core,
202        })
203    }
204}
205
206impl<W: FlushWriter + ?Sized> StreamingResponse<'_, W> {
207    /// Render until the first runtime boundary occurrence or terminal.
208    pub fn start(&mut self, state: &Value) -> Result<StreamStatus> {
209        let (core, call) = self.parts();
210        core.start(call, state)
211    }
212
213    /// Commit the pending occurrence through its checkpoint, then stop.
214    ///
215    /// The bytes written by this call are exactly the occurrence's own record —
216    /// no parent or tail bytes follow it — so the host can release the
217    /// occurrence the moment it resolves. Call [`Self::advance`] next.
218    ///
219    /// [`BoundaryMode::Updatable`] is refused once the response has committed
220    /// as many updatable occurrences as the browser retains. The refusal is
221    /// raised before any byte or state moves, so the same occurrence stays
222    /// pending and can be committed with [`BoundaryMode::Final`] instead.
223    pub fn resume(
224        &mut self,
225        instance_id: BoundaryInstanceId,
226        state: &Value,
227        mode: BoundaryMode,
228    ) -> Result<StreamStatus> {
229        let (core, call) = self.parts();
230        core.resume(call, instance_id, state, mode)
231    }
232
233    /// Write the ordinary parent bytes that follow a committed occurrence.
234    ///
235    /// Valid only after [`Self::resume`]. Renders the shell until the next
236    /// occurrence suspends or the terminal record completes.
237    pub fn advance(&mut self) -> Result<StreamStatus> {
238        let (core, call) = self.parts();
239        core.advance(call)
240    }
241
242    /// Emit one projected state update for a committed updatable occurrence.
243    ///
244    /// Valid between [`Self::resume`] and [`Self::advance`] as well as after
245    /// the response has moved on, so a host can revise the occurrence it just
246    /// committed while the response is still open.
247    pub fn update(&mut self, instance_id: BoundaryInstanceId, patch: &Value) -> Result<()> {
248        let (core, call) = self.parts();
249        core.update(call, instance_id, patch)
250    }
251
252    /// Commit the pending occurrence and continue to the next one.
253    ///
254    /// Used by [`WebUIHandler::render_streaming`], which has no host to hand
255    /// the checkpoint bytes to between the two steps and drives every
256    /// occurrence from the single value it already snapshotted at start.
257    pub(crate) fn resume_current_and_advance(
258        &mut self,
259        instance_id: BoundaryInstanceId,
260        mode: BoundaryMode,
261    ) -> Result<StreamStatus> {
262        let (core, call) = self.parts();
263        core.resume_current_and_advance(call, instance_id, mode)
264    }
265
266    /// Whether the response has emitted its terminal and ended its writer.
267    #[must_use]
268    pub fn is_done(&self) -> bool {
269        self.core.done
270    }
271
272    /// Split the response into its continuation and one call's borrowed
273    /// runtime, so every entry point shares the same wiring.
274    fn parts(&mut self) -> (&mut SessionCore, SessionCall<'_, '_>) {
275        let Self {
276            handler,
277            protocol,
278            options,
279            sink,
280            core,
281        } = self;
282        (
283            core,
284            SessionCall {
285                handler,
286                protocol,
287                options,
288                writer: sink,
289            },
290        )
291    }
292}
293
294pub(crate) struct SessionCore {
295    pub(crate) vm: ContinuationVm,
296    frozen_keys: Arc<[Box<str>]>,
297    requires_full_state: bool,
298    frozen_state: Value,
299    started: bool,
300    pub(crate) done: bool,
301    failed: bool,
302    /// True between a committed occurrence and the `advance` that writes the
303    /// parent bytes following it.
304    awaiting_advance: bool,
305    local_vars: HashMap<String, Value>,
306    component_attrs: HashMap<String, Value>,
307    route_base: Option<String>,
308    rendered_components: HashSet<String>,
309    document_style_resources: HashSet<String>,
310    shadow_style_roots: Vec<crate::ShadowStyleRoot>,
311    plugin: Option<Box<dyn HandlerPlugin>>,
312    route_children: Vec<webui_protocol::WebUiFragmentRoute>,
313    head_end_emitted: bool,
314    body_start_emitted: bool,
315    component_asset_styles_emitted: bool,
316    body_end_emitted: bool,
317    route_chain_index: usize,
318    route_chain: Option<Vec<crate::route_handler::RouteChainEntry>>,
319    route_document_style_targets: Vec<bool>,
320    reachable_components: Option<Vec<String>>,
321    streaming: Option<StreamingProgress>,
322    json_scratch: Vec<u8>,
323    scope_pool: Vec<HashMap<String, Value>>,
324}
325
326pub(crate) struct SessionCall<'call, 'data> {
327    pub(crate) handler: &'call WebUIHandler,
328    pub(crate) protocol: &'data Protocol,
329    pub(crate) options: &'call RenderOptions<'data>,
330    pub(crate) writer: &'call mut dyn ResponseWriter,
331}
332
333impl SessionCore {
334    pub(crate) fn new(handler: &WebUIHandler, protocol: &Protocol, entry_id: &str) -> Result<Self> {
335        if !protocol.protocol().fragments.contains_key(entry_id) {
336            return Err(HandlerError::MissingFragment(entry_id.to_string()));
337        }
338        let state_plan = protocol.continuation_state_plan(entry_id)?.resolve()?;
339        Ok(Self {
340            vm: ContinuationVm::new(entry_id, protocol)?,
341            frozen_keys: Arc::clone(&state_plan.keys),
342            requires_full_state: state_plan.requires_full_state,
343            frozen_state: Value::Object(serde_json::Map::new()),
344            started: false,
345            done: false,
346            failed: false,
347            awaiting_advance: false,
348            local_vars: HashMap::new(),
349            component_attrs: HashMap::new(),
350            route_base: None,
351            rendered_components: HashSet::new(),
352            document_style_resources: HashSet::new(),
353            shadow_style_roots: Vec::new(),
354            plugin: handler.plugin_factory.map(|factory| factory()),
355            route_children: Vec::new(),
356            head_end_emitted: false,
357            body_start_emitted: false,
358            component_asset_styles_emitted: false,
359            body_end_emitted: false,
360            route_chain_index: 0,
361            route_chain: None,
362            route_document_style_targets: Vec::new(),
363            reachable_components: None,
364            streaming: Some(StreamingProgress::new(
365                protocol.component_index().len(),
366                protocol.style_resource_index().len(),
367            )),
368            json_scratch: Vec::new(),
369            scope_pool: Vec::new(),
370        })
371    }
372
373    pub(crate) fn start(
374        &mut self,
375        call: SessionCall<'_, '_>,
376        state: &Value,
377    ) -> Result<StreamStatus> {
378        self.require_usable("start")?;
379        if self.started {
380            return Err(boundary_order_error(
381                "start",
382                "the streaming response has already started",
383            ));
384        }
385        // The value is moved into each render context and back, so protocols
386        // requiring full projection pay for exactly one response-local clone.
387        self.frozen_state = if self.requires_full_state {
388            state.clone()
389        } else {
390            selected_state_snapshot(state, &self.frozen_keys)
391        };
392        self.started = true;
393        self.run_step(call, StepGoal::NextBoundary, None)
394    }
395
396    pub(crate) fn resume(
397        &mut self,
398        call: SessionCall<'_, '_>,
399        instance_id: BoundaryInstanceId,
400        state: &Value,
401        mode: BoundaryMode,
402    ) -> Result<StreamStatus> {
403        self.require_resumable()?;
404        self.vm.validate_resume(instance_id)?;
405        self.vm.validate_resume_mode(mode)?;
406        if self.requires_full_state {
407            overlay_full_state(&mut self.frozen_state, state);
408        } else {
409            overlay_selected_state(&mut self.frozen_state, state, &self.frozen_keys);
410        }
411        self.run_step(call, StepGoal::CommitBoundary, Some((instance_id, mode)))
412    }
413
414    pub(crate) fn advance(&mut self, call: SessionCall<'_, '_>) -> Result<StreamStatus> {
415        self.require_advanceable()?;
416        self.run_step(call, StepGoal::NextBoundary, None)
417    }
418
419    /// Commit the pending occurrence and continue to the next one in a single
420    /// continuation setup.
421    ///
422    /// The public step machine deliberately returns at the checkpoint so a host
423    /// can write and release that occurrence on its own. The one-shot
424    /// [`WebUIHandler::render_streaming`] helper has no host in between, and
425    /// splitting the work would cost a second continuation hand-off — every
426    /// retained scope map, inventory buffer, and scratch vector moving in and
427    /// back out — plus a re-resolution of the parked record the step-local
428    /// cache already holds. [`StepGoal::NextBoundary`] commits the active
429    /// occurrence and keeps walking, so the fused call emits exactly the bytes
430    /// and flushes the split steps would.
431    pub(crate) fn resume_current_and_advance(
432        &mut self,
433        call: SessionCall<'_, '_>,
434        instance_id: BoundaryInstanceId,
435        mode: BoundaryMode,
436    ) -> Result<StreamStatus> {
437        self.require_resumable()?;
438        self.vm.validate_resume(instance_id)?;
439        self.vm.validate_resume_mode(mode)?;
440        self.run_step(call, StepGoal::NextBoundary, Some((instance_id, mode)))
441    }
442
443    pub(crate) fn update(
444        &mut self,
445        call: SessionCall<'_, '_>,
446        instance_id: BoundaryInstanceId,
447        patch: &Value,
448    ) -> Result<()> {
449        self.require_usable("update")?;
450        self.require_started("update")?;
451        if self.done {
452            return Err(boundary_order_error(
453                "update",
454                "the streaming response has already completed",
455            ));
456        }
457        if !patch.is_object() {
458            return Err(state_update_type_error());
459        }
460        let target = self.vm.validate_update(instance_id)?;
461        let handler = call.handler;
462        let result = self.with_context(call, patch, |_, context| {
463            let sequence = super::streaming_state(context)?.next_record_sequence;
464            handler.emit_streaming_state_update(sequence, target, context)?;
465            increment_streaming_record_sequence("update", super::streaming_state(context)?)
466        });
467        if result.is_err() {
468            self.failed = true;
469        }
470        result
471    }
472
473    fn run_step(
474        &mut self,
475        call: SessionCall<'_, '_>,
476        goal: StepGoal,
477        resume: Option<(BoundaryInstanceId, BoundaryMode)>,
478    ) -> Result<StreamStatus> {
479        let state = std::mem::replace(
480            &mut self.frozen_state,
481            Value::Object(serde_json::Map::new()),
482        );
483        let handler = call.handler;
484        let protocol = call.protocol;
485        let result = self.with_context(call, &state, |vm, context| {
486            if let Some((instance_id, mode)) = resume {
487                vm.begin_resume(instance_id, mode, context)?;
488            }
489            let status = vm.advance(goal, handler, protocol, context)?;
490            if !status.done {
491                context.writer.stream_flush()?;
492            }
493            Ok(status)
494        });
495        self.frozen_state = state;
496        match result {
497            Ok(status) => {
498                if status.done && !self.shadow_style_roots.is_empty() {
499                    self.failed = true;
500                    return Err(HandlerError::Invariant(
501                        "a Shadow CSS tree escaped its component instance".to_string(),
502                    ));
503                }
504                self.done = status.done;
505                self.awaiting_advance = goal == StepGoal::CommitBoundary && !status.done;
506                Ok(status)
507            }
508            Err(error) => {
509                self.failed = true;
510                Err(error)
511            }
512        }
513    }
514
515    fn with_context<'data, 'state, T>(
516        &mut self,
517        call: SessionCall<'_, 'data>,
518        state: &'state Value,
519        operation: impl for<'output> FnOnce(
520            &mut ContinuationVm,
521            &mut WebUIProcessContext<'data, 'state, 'output>,
522        ) -> Result<T>,
523    ) -> Result<T> {
524        let SessionCall {
525            protocol,
526            options,
527            writer,
528            ..
529        } = call;
530        let component_asset_style_manifest = protocol.component_asset_style_manifest()?;
531        let progress = self.streaming.take().ok_or_else(missing_progress_error)?;
532        let mut streaming =
533            StreamingRenderState::from_progress(progress, protocol.component_reachability());
534        let mut context = WebUIProcessContext {
535            protocol: protocol.protocol(),
536            render_fragments: protocol.render_fragments().resolve(protocol.protocol()),
537            component_asset_style_manifest,
538            component_asset_style_links: protocol.component_asset_style_links(),
539            state,
540            writer,
541            local_vars: std::mem::take(&mut self.local_vars),
542            local_borrowed_vars: super::super::BorrowedScope::default(),
543            loop_vars: Vec::new(),
544            visible_loop_scope: super::super::VisibleLoopScope::EMPTY,
545            component_attrs: std::mem::take(&mut self.component_attrs),
546            component_borrowed_attrs: super::super::BorrowedScope::default(),
547            collecting_component_attrs: false,
548            request_path: options.request_path,
549            route_base: self
550                .route_base
551                .take()
552                .map_or(Cow::Borrowed("/"), Cow::Owned),
553            rendered_components: std::mem::take(&mut self.rendered_components),
554            plugin: self.plugin.take(),
555            route_children: std::mem::take(&mut self.route_children),
556            entry_id: options.entry_id,
557            nonce: options.nonce.filter(|nonce| !nonce.is_empty()),
558            component_index: protocol.component_index(),
559            style_resource_index: protocol.style_resource_index(),
560            style_chunk_index: protocol.protocol().style_chunk_index(),
561            css_strategy: protocol.css_strategy(),
562            head_inject: options.head_inject.filter(|html| !html.is_empty()),
563            body_inject: options.body_inject.filter(|html| !html.is_empty()),
564            state_inject: crate::StateInject::resolve(state),
565            head_end_emitted: self.head_end_emitted,
566            body_start_emitted: self.body_start_emitted,
567            component_asset_styles_emitted: self.component_asset_styles_emitted,
568            body_end_emitted: self.body_end_emitted,
569            route_index: protocol.route_index(),
570            route_chain_index: self.route_chain_index,
571            route_chain: std::mem::take(&mut self.route_chain),
572            route_document_style_targets: std::mem::take(&mut self.route_document_style_targets),
573            reachable_components: std::mem::take(&mut self.reachable_components),
574            streaming: Some(&mut streaming),
575            json_scratch: std::mem::take(&mut self.json_scratch),
576            scope_pool: std::mem::take(&mut self.scope_pool),
577            document_style_resources: std::mem::take(&mut self.document_style_resources),
578            shadow_style_roots: std::mem::take(&mut self.shadow_style_roots),
579            borrowed_scope_pool: Vec::new(),
580        };
581        let result = operation(&mut self.vm, &mut context);
582        self.local_vars = std::mem::take(&mut context.local_vars);
583        self.component_attrs = std::mem::take(&mut context.component_attrs);
584        self.route_base = match std::mem::replace(&mut context.route_base, Cow::Borrowed("/")) {
585            Cow::Owned(base) => Some(base),
586            Cow::Borrowed(_) => None,
587        };
588        self.rendered_components = std::mem::take(&mut context.rendered_components);
589        self.plugin = context.plugin.take();
590        self.route_children = std::mem::take(&mut context.route_children);
591        self.head_end_emitted = context.head_end_emitted;
592        self.body_start_emitted = context.body_start_emitted;
593        self.component_asset_styles_emitted = context.component_asset_styles_emitted;
594        self.body_end_emitted = context.body_end_emitted;
595        self.route_chain_index = context.route_chain_index;
596        self.route_chain = std::mem::take(&mut context.route_chain);
597        self.route_document_style_targets =
598            std::mem::take(&mut context.route_document_style_targets);
599        self.reachable_components = std::mem::take(&mut context.reachable_components);
600        self.json_scratch = std::mem::take(&mut context.json_scratch);
601        self.scope_pool = std::mem::take(&mut context.scope_pool);
602        self.shadow_style_roots = std::mem::take(&mut context.shadow_style_roots);
603        self.document_style_resources = std::mem::take(&mut context.document_style_resources);
604        self.streaming = Some(streaming.into_progress());
605        result
606    }
607
608    fn require_usable(&self, operation: &str) -> Result<()> {
609        if self.failed {
610            return Err(boundary_order_error(
611                operation,
612                "the session is poisoned by a previous render or transport failure; start a new response",
613            ));
614        }
615        Ok(())
616    }
617
618    fn require_started(&self, operation: &str) -> Result<()> {
619        if !self.started {
620            return Err(boundary_order_error(
621                operation,
622                "start must be called before this operation",
623            ));
624        }
625        Ok(())
626    }
627
628    /// Reject a `resume` that is out of order without writing a byte.
629    ///
630    /// Ordering is checked before any render work, so a rejected call leaves
631    /// the response exactly as it was and the host can retry with the right
632    /// instance ID or the missing `advance`.
633    fn require_resumable(&self) -> Result<()> {
634        self.require_usable("resume")?;
635        self.require_started("resume")?;
636        if self.done {
637            return Err(boundary_order_error(
638                "resume",
639                "the streaming response has already completed",
640            ));
641        }
642        if self.awaiting_advance {
643            return Err(boundary_order_error(
644                "resume",
645                "the previously committed boundary has not been advanced past; call advance \
646                 before resuming the next occurrence",
647            ));
648        }
649        Ok(())
650    }
651
652    /// Reject an `advance` that is out of order without writing a byte.
653    fn require_advanceable(&self) -> Result<()> {
654        self.require_usable("advance")?;
655        self.require_started("advance")?;
656        if self.done {
657            return Err(boundary_order_error(
658                "advance",
659                "the streaming response has already completed",
660            ));
661        }
662        if !self.awaiting_advance {
663            return Err(boundary_order_error(
664                "advance",
665                "there is no committed boundary to advance past; resume the pending occurrence \
666                 first",
667            ));
668        }
669        Ok(())
670    }
671}
672
673#[cold]
674#[inline(never)]
675fn missing_progress_error() -> HandlerError {
676    HandlerError::Invariant("streaming progress is unavailable".to_string())
677}
678
679#[cfg(test)]
680mod tests {
681    use super::*;
682    use crate::{FlushWriter, ResponseWriter};
683    use webui_parser::{ComponentRegistration, HtmlParser};
684    use webui_protocol::{ComponentData, InitialStateStrategy, StateProjectionMode, WebUIProtocol};
685    use webui_test_utils::test_json;
686
687    const ISLAND_TAG: &str = "state-island";
688
689    struct TestSink {
690        output: String,
691    }
692
693    impl ResponseWriter for TestSink {
694        fn write(&mut self, content: &str) -> Result<()> {
695            self.output.push_str(content);
696            Ok(())
697        }
698
699        fn end(&mut self) -> Result<()> {
700            Ok(())
701        }
702    }
703
704    impl FlushWriter for TestSink {
705        fn flush(&mut self) -> Result<()> {
706            Ok(())
707        }
708    }
709
710    /// A sink whose bytes stay readable while the response holds it, so a test
711    /// can prove a refused step wrote nothing.
712    #[derive(Clone, Default)]
713    struct SharedSink(std::rc::Rc<std::cell::RefCell<String>>);
714
715    impl ResponseWriter for SharedSink {
716        fn write(&mut self, content: &str) -> Result<()> {
717            self.0.borrow_mut().push_str(content);
718            Ok(())
719        }
720
721        fn end(&mut self) -> Result<()> {
722            Ok(())
723        }
724    }
725
726    impl FlushWriter for SharedSink {
727        fn flush(&mut self) -> Result<()> {
728            Ok(())
729        }
730    }
731
732    /// Build a parser-produced entry with `boundaries` runtime occurrences,
733    /// each hosting one island component.
734    fn boundary_protocol(boundaries: usize, hydration_mode: StateProjectionMode) -> Protocol {
735        let mut html = String::from("<!doctype html><html><head></head><body>");
736        for sequence in 0..boundaries {
737            html.push_str("<boundary name=\"b");
738            html.push_str(&sequence.to_string());
739            html.push_str("\"><article><");
740            html.push_str(ISLAND_TAG);
741            html.push_str("></");
742            html.push_str(ISLAND_TAG);
743            html.push_str("></article></boundary>");
744        }
745        html.push_str("</body></html>");
746
747        let mut parser = HtmlParser::new();
748        match parser
749            .component_registry_mut()
750            .register_component(ComponentRegistration::new(
751                ISLAND_TAG,
752                "<button>{{title}}</button>",
753                None,
754                true,
755            )) {
756            Ok(()) => {}
757            Err(error) => panic!("registering the island failed: {error}"),
758        }
759        if let Err(error) = parser.parse("index.html", &html) {
760            panic!("parsing the streaming entry failed: {error}");
761        }
762        let mut document = WebUIProtocol::new(parser.into_fragment_records());
763        document.initial_state_strategy = InitialStateStrategy::Components as i32;
764        document.components.insert(
765            ISLAND_TAG.to_string(),
766            ComponentData {
767                template_json: r#"{"h":"<button></button>","th":1}"#.to_string(),
768                uses_shadow_dom: true,
769                hydration_mode: hydration_mode as i32,
770                hydration_keys: if matches!(hydration_mode, StateProjectionMode::Keys) {
771                    vec!["count".to_string(), "title".to_string()]
772                } else {
773                    Vec::new()
774                },
775                ..Default::default()
776            },
777        );
778        document.populate_style_closures(&["index.html"]);
779        Protocol::new(document)
780    }
781
782    /// A state whose payload is large enough that a per-boundary copy would be
783    /// unmistakable in both time and allocation.
784    fn large_state(rows: usize) -> Value {
785        let mut items = Vec::with_capacity(rows);
786        for row in 0..rows {
787            items.push(test_json!({
788                "id": row,
789                "label": format!("row-{row}"),
790                "tags": ["alpha", "beta", "gamma"],
791            }));
792        }
793        test_json!({
794            "count": 42,
795            "title": "large state",
796            "rows": items,
797        })
798    }
799
800    /// Heap address of the retained snapshot's `rows` buffer, or `None` when
801    /// the snapshot does not hold it.
802    fn rows_address(state: &Value) -> Option<usize> {
803        state
804            .get("rows")
805            .and_then(Value::as_array)
806            .map(|rows| rows.as_ptr().addr())
807    }
808
809    fn options<'a>() -> RenderOptions<'a> {
810        RenderOptions::new("index.html", "/")
811    }
812
813    #[test]
814    fn render_streaming_projects_full_state_once_per_response() -> Result<()> {
815        // Full-state protocols retain the caller's whole tree. Committing each
816        // occurrence against the snapshot the response already holds must not
817        // re-copy that tree, so the retained buffer keeps its identity for the
818        // entire response no matter how many boundaries commit.
819        let protocol = boundary_protocol(8, StateProjectionMode::All);
820        let handler = WebUIHandler::new();
821        let state = large_state(256);
822        let render_options = options();
823        let mut sink = TestSink {
824            output: String::new(),
825        };
826        let mut response = handler.stream_response(&protocol, &render_options, &mut sink)?;
827
828        let mut status = response.start(&state)?;
829        let snapshot = rows_address(&response.core.frozen_state);
830        assert!(
831            snapshot.is_some(),
832            "a full-state protocol must retain the caller's payload"
833        );
834        assert_ne!(
835            snapshot,
836            rows_address(&state),
837            "the response owns its snapshot rather than borrowing the caller's tree"
838        );
839
840        let mut committed = 0usize;
841        while !status.done {
842            status = match status.boundary.as_ref() {
843                Some(boundary) => {
844                    let next =
845                        response.resume(boundary.instance_id, &state, BoundaryMode::Final)?;
846                    committed += 1;
847                    assert!(
848                        next.boundary.is_none() && !next.done,
849                        "a commit step stops at its checkpoint and waits for advance"
850                    );
851                    assert_eq!(
852                        rows_address(&response.core.frozen_state),
853                        snapshot,
854                        "committing occurrence {committed} must not re-copy the retained snapshot"
855                    );
856                    next
857                }
858                None => response.advance()?,
859            };
860        }
861        assert_eq!(committed, 8, "every authored boundary must commit");
862
863        // The one-shot helper fuses commit and advance into one continuation
864        // setup, so its bytes must match the split steps it stands in for.
865        let mut helper_sink = TestSink {
866            output: String::new(),
867        };
868        handler.render_streaming(&protocol, &state, &render_options, &mut helper_sink)?;
869        assert_eq!(
870            helper_sink.output, sink.output,
871            "render_streaming must resume against the retained snapshot"
872        );
873        Ok(())
874    }
875
876    #[test]
877    fn fused_helper_matches_split_step_bytes_and_flushes() -> Result<()> {
878        // `render_streaming` skips the host hand-off between a commit and the
879        // parent bytes that follow it. That is a scheduling shortcut only: the
880        // emitted bytes and the positions at which they are flushed must match
881        // the split step machine exactly.
882        #[derive(Default)]
883        struct FlushSink {
884            output: String,
885            flushes: Vec<usize>,
886        }
887
888        impl ResponseWriter for FlushSink {
889            fn write(&mut self, content: &str) -> Result<()> {
890                self.output.push_str(content);
891                Ok(())
892            }
893
894            fn end(&mut self) -> Result<()> {
895                Ok(())
896            }
897        }
898
899        impl FlushWriter for FlushSink {
900            fn flush(&mut self) -> Result<()> {
901                self.flushes.push(self.output.len());
902                Ok(())
903            }
904        }
905
906        let protocol = boundary_protocol(4, StateProjectionMode::Keys);
907        let handler = WebUIHandler::new();
908        let state = test_json!({ "count": 3, "title": "fused" });
909        let render_options = options();
910
911        let mut split = FlushSink::default();
912        {
913            let mut response = handler.stream_response(&protocol, &render_options, &mut split)?;
914            let mut status = response.start(&state)?;
915            while !status.done {
916                status = match status.boundary.as_ref() {
917                    Some(boundary) => {
918                        response.resume(boundary.instance_id, &state, BoundaryMode::Final)?
919                    }
920                    None => response.advance()?,
921                };
922            }
923        }
924
925        let mut fused = FlushSink::default();
926        handler.render_streaming(&protocol, &state, &render_options, &mut fused)?;
927
928        assert_eq!(fused.output, split.output, "bytes must match");
929        assert_eq!(fused.flushes, split.flushes, "flush positions must match");
930        Ok(())
931    }
932
933    #[test]
934    fn resume_overlays_new_state_and_reuses_unchanged_subtrees() -> Result<()> {
935        // The public resume keeps its patch semantics: a changed key lands in
936        // the snapshot, an omitted key survives, and an unchanged subtree is
937        // left in place instead of being copied again.
938        let protocol = boundary_protocol(2, StateProjectionMode::All);
939        let handler = WebUIHandler::new();
940        let state = large_state(64);
941        let render_options = options();
942        let mut sink = TestSink {
943            output: String::new(),
944        };
945        let mut response = handler.stream_response(&protocol, &render_options, &mut sink)?;
946
947        let status = response.start(&state)?;
948        let snapshot = rows_address(&response.core.frozen_state);
949        let Some(boundary) = status.boundary.as_ref() else {
950            panic!("the first occurrence must suspend");
951        };
952
953        let mut next = state.clone();
954        if let Some(object) = next.as_object_mut() {
955            object.insert("title".to_string(), Value::String("second".to_string()));
956            object.remove("count");
957        }
958        let status = response.resume(boundary.instance_id, &next, BoundaryMode::Final)?;
959
960        assert_eq!(
961            response.core.frozen_state.get("title"),
962            Some(&Value::String("second".to_string())),
963            "a changed key must land in the snapshot"
964        );
965        assert_eq!(
966            response.core.frozen_state.get("count"),
967            Some(&test_json!(42)),
968            "an omitted key keeps the value the snapshot already holds"
969        );
970        assert_eq!(
971            rows_address(&response.core.frozen_state),
972            snapshot,
973            "an unchanged subtree must not be copied again"
974        );
975        assert!(
976            status.boundary.is_none() && !status.done,
977            "the commit step stops at its checkpoint"
978        );
979        assert!(
980            response.advance()?.boundary.is_some(),
981            "the second occurrence follows the parent bytes"
982        );
983        Ok(())
984    }
985
986    #[test]
987    fn semantic_steps_reuse_projection_scratch() -> Result<()> {
988        // The record projection scratch lives in the retained progress as plain
989        // integers, so after the first record every later step reuses the same
990        // allocation instead of rebuilding one per step.
991        let protocol = boundary_protocol(6, StateProjectionMode::Keys);
992        let handler = WebUIHandler::new();
993        let state = test_json!({ "count": 1, "title": "scratch", "unused": "x" });
994        let render_options = options();
995        let mut sink = TestSink {
996            output: String::new(),
997        };
998        let mut response = handler.stream_response(&protocol, &render_options, &mut sink)?;
999
1000        let mut status = response.start(&state)?;
1001        let mut retained: Option<(usize, usize)> = None;
1002        while !status.done {
1003            let Some(boundary) = status.boundary.as_ref() else {
1004                status = response.advance()?;
1005                continue;
1006            };
1007            status = response.resume(boundary.instance_id, &state, BoundaryMode::Updatable)?;
1008            let Some(progress) = response.core.streaming.as_ref() else {
1009                panic!("a suspended response must retain its progress");
1010            };
1011            let observed = (
1012                progress.state_key_ids.capacity(),
1013                progress.state_key_ids.as_ptr().addr(),
1014            );
1015            assert!(
1016                observed.0 > 0,
1017                "the projection scratch must survive the record that filled it"
1018            );
1019            match retained {
1020                None => retained = Some(observed),
1021                Some(previous) => assert_eq!(
1022                    observed, previous,
1023                    "a later step must reuse the projection buffer, not allocate a new one"
1024                ),
1025            }
1026        }
1027        Ok(())
1028    }
1029
1030    #[test]
1031    fn updates_reuse_their_committed_projection_buffer() -> Result<()> {
1032        // An update writes through the plan captured at commit time: no key
1033        // list is rebuilt, so the plan's buffer keeps its identity across every
1034        // update it serves.
1035        let protocol = boundary_protocol(2, StateProjectionMode::Keys);
1036        let handler = WebUIHandler::new();
1037        let state = test_json!({ "count": 1, "title": "updates" });
1038        let render_options = options();
1039        let mut sink = TestSink {
1040            output: String::new(),
1041        };
1042        let mut response = handler.stream_response(&protocol, &render_options, &mut sink)?;
1043
1044        let status = response.start(&state)?;
1045        let Some(boundary) = status.boundary.as_ref() else {
1046            panic!("the first occurrence must suspend");
1047        };
1048        let instance_id = boundary.instance_id;
1049        response.resume(instance_id, &state, BoundaryMode::Updatable)?;
1050
1051        let mut retained: Option<(usize, usize)> = None;
1052        for _ in 0..4 {
1053            response.update(instance_id, &state)?;
1054            let Some(progress) = response.core.streaming.as_ref() else {
1055                panic!("a live response must retain its progress");
1056            };
1057            let Some(Some(plan)) = progress.update_plans.first() else {
1058                panic!("an updatable occurrence must retain its projection plan");
1059            };
1060            let observed = (plan.key_ids.capacity(), plan.key_ids.as_ptr().addr());
1061            assert!(!plan.requires_full_state, "keyed islands project keys");
1062            assert!(observed.0 > 0, "the plan must retain its key buffer");
1063            match retained {
1064                None => retained = Some(observed),
1065                Some(previous) => assert_eq!(
1066                    observed, previous,
1067                    "every update must reuse the committed projection buffer"
1068                ),
1069            }
1070        }
1071        assert_eq!(
1072            sink.output.matches(",2,0,{").count(),
1073            4,
1074            "each update emits exactly one typed state-update record"
1075        );
1076        Ok(())
1077    }
1078
1079    /// Drive `count` occurrences to completion in `mode`, returning the status
1080    /// the response stopped on.
1081    fn commit_occurrences<W: FlushWriter + ?Sized>(
1082        response: &mut StreamingResponse<'_, W>,
1083        status: StreamStatus,
1084        state: &Value,
1085        count: usize,
1086        mode: BoundaryMode,
1087    ) -> Result<StreamStatus> {
1088        let mut status = status;
1089        for committed in 0..count {
1090            let Some(boundary) = status.boundary.as_ref() else {
1091                panic!("occurrence {committed} must suspend before it can commit");
1092            };
1093            let instance_id = boundary.instance_id;
1094            response.resume(instance_id, state, mode)?;
1095            status = response.advance()?;
1096        }
1097        Ok(status)
1098    }
1099
1100    #[test]
1101    fn updatable_commits_stop_at_the_browser_retention_cap() -> Result<()> {
1102        // The browser retains every updatable occurrence for the life of the
1103        // response and refuses the one past its cap, so the server refuses to
1104        // emit that checkpoint at all. The refusal lands before a byte is
1105        // written and before the caller's state reaches the snapshot, leaving
1106        // the same occurrence pending so the host can commit it as final.
1107        let protocol = boundary_protocol(MAX_UPDATABLE_OCCURRENCES + 1, StateProjectionMode::Keys);
1108        let handler = WebUIHandler::new();
1109        let state = test_json!({ "count": 1, "title": "retained" });
1110        let render_options = options();
1111        let mut sink = SharedSink::default();
1112        let written = SharedSink::clone(&sink).0;
1113        let mut response = handler.stream_response(&protocol, &render_options, &mut sink)?;
1114
1115        let status = response.start(&state)?;
1116        let status = commit_occurrences(
1117            &mut response,
1118            status,
1119            &state,
1120            MAX_UPDATABLE_OCCURRENCES,
1121            BoundaryMode::Updatable,
1122        )?;
1123
1124        let Some(boundary) = status.boundary.as_ref() else {
1125            panic!("the occurrence past the cap must suspend like any other");
1126        };
1127        let instance_id = boundary.instance_id;
1128        let bytes_before = written.borrow().len();
1129        let refused = test_json!({ "count": 2, "title": "refused" });
1130        let rejected = response.resume(instance_id, &refused, BoundaryMode::Updatable);
1131        match rejected {
1132            Err(HandlerError::StreamingBoundary(error)) => {
1133                assert_eq!(error.signal, "resume");
1134                assert!(
1135                    error.reason.contains("BoundaryMode::Final"),
1136                    "the refusal must name the recovery: {}",
1137                    error.reason
1138                );
1139            }
1140            _ => panic!("committing past the cap must fail with a typed boundary error"),
1141        }
1142        assert_eq!(
1143            written.borrow().len(),
1144            bytes_before,
1145            "a refused commit must not write a byte"
1146        );
1147        assert_eq!(
1148            response.core.frozen_state.get("title"),
1149            Some(&Value::String("retained".to_string())),
1150            "a refused commit must not merge its state into the snapshot"
1151        );
1152
1153        // The occurrence is untouched, so the same ID commits as final.
1154        let status = response.resume(instance_id, &state, BoundaryMode::Final)?;
1155        assert!(
1156            status.boundary.is_none() && !status.done,
1157            "the retried commit stops at its own checkpoint"
1158        );
1159        assert!(
1160            written.borrow().len() > bytes_before,
1161            "the retry writes its checkpoint"
1162        );
1163        assert!(
1164            response.advance()?.done,
1165            "the response still reaches its terminal"
1166        );
1167        Ok(())
1168    }
1169
1170    #[test]
1171    fn final_commits_do_not_consume_the_updatable_cap() -> Result<()> {
1172        // Only occurrences the browser retains count against the cap: a final
1173        // boundary releases its roots at hydration, so a response may commit
1174        // any number of them and still use its full updatable budget.
1175        let protocol = boundary_protocol(MAX_UPDATABLE_OCCURRENCES + 2, StateProjectionMode::Keys);
1176        let handler = WebUIHandler::new();
1177        let state = test_json!({ "count": 1, "title": "mixed" });
1178        let render_options = options();
1179        let mut sink = TestSink {
1180            output: String::new(),
1181        };
1182        let mut response = handler.stream_response(&protocol, &render_options, &mut sink)?;
1183
1184        let status = response.start(&state)?;
1185        let status = commit_occurrences(&mut response, status, &state, 2, BoundaryMode::Final)?;
1186        let status = commit_occurrences(
1187            &mut response,
1188            status,
1189            &state,
1190            MAX_UPDATABLE_OCCURRENCES,
1191            BoundaryMode::Updatable,
1192        )?;
1193        assert!(
1194            status.done && response.is_done(),
1195            "final commits must leave the whole updatable budget available"
1196        );
1197        Ok(())
1198    }
1199
1200    #[test]
1201    fn owned_sessions_enforce_the_same_updatable_cap() -> Result<()> {
1202        // The owned session shares the borrowed session's continuation, so the
1203        // cap, the refusal, and the final-mode retry behave identically.
1204        let protocol = Arc::new(boundary_protocol(
1205            MAX_UPDATABLE_OCCURRENCES + 1,
1206            StateProjectionMode::Keys,
1207        ));
1208        let mut session = crate::streaming::StreamingSession::new(
1209            Arc::new(WebUIHandler::new()),
1210            protocol,
1211            crate::streaming::SessionOptions::new("index.html", "/"),
1212        )?;
1213        let state = test_json!({ "count": 1, "title": "owned" });
1214
1215        let mut step = session.start(&state)?;
1216        for committed in 0..MAX_UPDATABLE_OCCURRENCES {
1217            let Some(boundary) = step.boundary.as_ref() else {
1218                panic!("occurrence {committed} must suspend before it can commit");
1219            };
1220            let instance_id = boundary.instance_id;
1221            session.resume(instance_id, &state, BoundaryMode::Updatable)?;
1222            step = session.advance()?;
1223        }
1224
1225        let Some(boundary) = step.boundary.as_ref() else {
1226            panic!("the occurrence past the cap must suspend like any other");
1227        };
1228        let instance_id = boundary.instance_id;
1229        assert!(
1230            session
1231                .resume(instance_id, &state, BoundaryMode::Updatable)
1232                .is_err(),
1233            "an owned session refuses the occurrence past the cap"
1234        );
1235        let step = session.resume(instance_id, &state, BoundaryMode::Final)?;
1236        assert!(
1237            !step.bytes.is_empty(),
1238            "the retried commit still delivers its checkpoint bytes"
1239        );
1240        assert!(
1241            session.advance()?.done,
1242            "the owned response still reaches its terminal"
1243        );
1244        Ok(())
1245    }
1246}