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_resources_requiring_escape: protocol.style_resources_requiring_escape(),
561            style_chunk_index: protocol.protocol().style_chunk_index(),
562            css_strategy: protocol.css_strategy(),
563            head_inject: options.head_inject.filter(|html| !html.is_empty()),
564            body_inject: options.body_inject.filter(|html| !html.is_empty()),
565            state_inject: crate::StateInject::resolve(state),
566            head_end_emitted: self.head_end_emitted,
567            body_start_emitted: self.body_start_emitted,
568            component_asset_styles_emitted: self.component_asset_styles_emitted,
569            body_end_emitted: self.body_end_emitted,
570            route_index: protocol.route_index(),
571            route_chain_index: self.route_chain_index,
572            route_chain: std::mem::take(&mut self.route_chain),
573            route_document_style_targets: std::mem::take(&mut self.route_document_style_targets),
574            reachable_components: std::mem::take(&mut self.reachable_components),
575            streaming: Some(&mut streaming),
576            json_scratch: std::mem::take(&mut self.json_scratch),
577            scope_pool: std::mem::take(&mut self.scope_pool),
578            document_style_resources: std::mem::take(&mut self.document_style_resources),
579            shadow_style_roots: std::mem::take(&mut self.shadow_style_roots),
580            borrowed_scope_pool: Vec::new(),
581        };
582        let result = operation(&mut self.vm, &mut context);
583        self.local_vars = std::mem::take(&mut context.local_vars);
584        self.component_attrs = std::mem::take(&mut context.component_attrs);
585        self.route_base = match std::mem::replace(&mut context.route_base, Cow::Borrowed("/")) {
586            Cow::Owned(base) => Some(base),
587            Cow::Borrowed(_) => None,
588        };
589        self.rendered_components = std::mem::take(&mut context.rendered_components);
590        self.plugin = context.plugin.take();
591        self.route_children = std::mem::take(&mut context.route_children);
592        self.head_end_emitted = context.head_end_emitted;
593        self.body_start_emitted = context.body_start_emitted;
594        self.component_asset_styles_emitted = context.component_asset_styles_emitted;
595        self.body_end_emitted = context.body_end_emitted;
596        self.route_chain_index = context.route_chain_index;
597        self.route_chain = std::mem::take(&mut context.route_chain);
598        self.route_document_style_targets =
599            std::mem::take(&mut context.route_document_style_targets);
600        self.reachable_components = std::mem::take(&mut context.reachable_components);
601        self.json_scratch = std::mem::take(&mut context.json_scratch);
602        self.scope_pool = std::mem::take(&mut context.scope_pool);
603        self.shadow_style_roots = std::mem::take(&mut context.shadow_style_roots);
604        self.document_style_resources = std::mem::take(&mut context.document_style_resources);
605        self.streaming = Some(streaming.into_progress());
606        result
607    }
608
609    fn require_usable(&self, operation: &str) -> Result<()> {
610        if self.failed {
611            return Err(boundary_order_error(
612                operation,
613                "the session is poisoned by a previous render or transport failure; start a new response",
614            ));
615        }
616        Ok(())
617    }
618
619    fn require_started(&self, operation: &str) -> Result<()> {
620        if !self.started {
621            return Err(boundary_order_error(
622                operation,
623                "start must be called before this operation",
624            ));
625        }
626        Ok(())
627    }
628
629    /// Reject a `resume` that is out of order without writing a byte.
630    ///
631    /// Ordering is checked before any render work, so a rejected call leaves
632    /// the response exactly as it was and the host can retry with the right
633    /// instance ID or the missing `advance`.
634    fn require_resumable(&self) -> Result<()> {
635        self.require_usable("resume")?;
636        self.require_started("resume")?;
637        if self.done {
638            return Err(boundary_order_error(
639                "resume",
640                "the streaming response has already completed",
641            ));
642        }
643        if self.awaiting_advance {
644            return Err(boundary_order_error(
645                "resume",
646                "the previously committed boundary has not been advanced past; call advance \
647                 before resuming the next occurrence",
648            ));
649        }
650        Ok(())
651    }
652
653    /// Reject an `advance` that is out of order without writing a byte.
654    fn require_advanceable(&self) -> Result<()> {
655        self.require_usable("advance")?;
656        self.require_started("advance")?;
657        if self.done {
658            return Err(boundary_order_error(
659                "advance",
660                "the streaming response has already completed",
661            ));
662        }
663        if !self.awaiting_advance {
664            return Err(boundary_order_error(
665                "advance",
666                "there is no committed boundary to advance past; resume the pending occurrence \
667                 first",
668            ));
669        }
670        Ok(())
671    }
672}
673
674#[cold]
675#[inline(never)]
676fn missing_progress_error() -> HandlerError {
677    HandlerError::Invariant("streaming progress is unavailable".to_string())
678}
679
680#[cfg(test)]
681mod tests {
682    use super::*;
683    use crate::{FlushWriter, ResponseWriter};
684    use webui_parser::{ComponentRegistration, HtmlParser};
685    use webui_protocol::{ComponentData, InitialStateStrategy, StateProjectionMode, WebUIProtocol};
686    use webui_test_utils::test_json;
687
688    const ISLAND_TAG: &str = "state-island";
689
690    struct TestSink {
691        output: String,
692    }
693
694    impl ResponseWriter for TestSink {
695        fn write(&mut self, content: &str) -> Result<()> {
696            self.output.push_str(content);
697            Ok(())
698        }
699
700        fn end(&mut self) -> Result<()> {
701            Ok(())
702        }
703    }
704
705    impl FlushWriter for TestSink {
706        fn flush(&mut self) -> Result<()> {
707            Ok(())
708        }
709    }
710
711    /// A sink whose bytes stay readable while the response holds it, so a test
712    /// can prove a refused step wrote nothing.
713    #[derive(Clone, Default)]
714    struct SharedSink(std::rc::Rc<std::cell::RefCell<String>>);
715
716    impl ResponseWriter for SharedSink {
717        fn write(&mut self, content: &str) -> Result<()> {
718            self.0.borrow_mut().push_str(content);
719            Ok(())
720        }
721
722        fn end(&mut self) -> Result<()> {
723            Ok(())
724        }
725    }
726
727    impl FlushWriter for SharedSink {
728        fn flush(&mut self) -> Result<()> {
729            Ok(())
730        }
731    }
732
733    /// Build a parser-produced entry with `boundaries` runtime occurrences,
734    /// each hosting one island component.
735    fn boundary_protocol(boundaries: usize, hydration_mode: StateProjectionMode) -> Protocol {
736        let mut html = String::from("<!doctype html><html><head></head><body>");
737        for sequence in 0..boundaries {
738            html.push_str("<boundary name=\"b");
739            html.push_str(&sequence.to_string());
740            html.push_str("\"><article><");
741            html.push_str(ISLAND_TAG);
742            html.push_str("></");
743            html.push_str(ISLAND_TAG);
744            html.push_str("></article></boundary>");
745        }
746        html.push_str("</body></html>");
747
748        let mut parser = HtmlParser::new();
749        match parser
750            .component_registry_mut()
751            .register_component(ComponentRegistration::new(
752                ISLAND_TAG,
753                "<button>{{title}}</button>",
754                None,
755                true,
756            )) {
757            Ok(()) => {}
758            Err(error) => panic!("registering the island failed: {error}"),
759        }
760        if let Err(error) = parser.parse("index.html", &html) {
761            panic!("parsing the streaming entry failed: {error}");
762        }
763        let mut document = WebUIProtocol::new(parser.into_fragment_records());
764        document.initial_state_strategy = InitialStateStrategy::Components as i32;
765        document.components.insert(
766            ISLAND_TAG.to_string(),
767            ComponentData {
768                template_json: r#"{"h":"<button></button>","th":1}"#.to_string(),
769                uses_shadow_dom: true,
770                hydration_mode: hydration_mode as i32,
771                hydration_keys: if matches!(hydration_mode, StateProjectionMode::Keys) {
772                    vec!["count".to_string(), "title".to_string()]
773                } else {
774                    Vec::new()
775                },
776                ..Default::default()
777            },
778        );
779        document.populate_style_closures(&["index.html"]);
780        Protocol::new(document)
781    }
782
783    /// A state whose payload is large enough that a per-boundary copy would be
784    /// unmistakable in both time and allocation.
785    fn large_state(rows: usize) -> Value {
786        let mut items = Vec::with_capacity(rows);
787        for row in 0..rows {
788            items.push(test_json!({
789                "id": row,
790                "label": format!("row-{row}"),
791                "tags": ["alpha", "beta", "gamma"],
792            }));
793        }
794        test_json!({
795            "count": 42,
796            "title": "large state",
797            "rows": items,
798        })
799    }
800
801    /// Heap address of the retained snapshot's `rows` buffer, or `None` when
802    /// the snapshot does not hold it.
803    fn rows_address(state: &Value) -> Option<usize> {
804        state
805            .get("rows")
806            .and_then(Value::as_array)
807            .map(|rows| rows.as_ptr().addr())
808    }
809
810    fn options<'a>() -> RenderOptions<'a> {
811        RenderOptions::new("index.html", "/")
812    }
813
814    #[test]
815    fn render_streaming_projects_full_state_once_per_response() -> Result<()> {
816        // Full-state protocols retain the caller's whole tree. Committing each
817        // occurrence against the snapshot the response already holds must not
818        // re-copy that tree, so the retained buffer keeps its identity for the
819        // entire response no matter how many boundaries commit.
820        let protocol = boundary_protocol(8, StateProjectionMode::All);
821        let handler = WebUIHandler::new();
822        let state = large_state(256);
823        let render_options = options();
824        let mut sink = TestSink {
825            output: String::new(),
826        };
827        let mut response = handler.stream_response(&protocol, &render_options, &mut sink)?;
828
829        let mut status = response.start(&state)?;
830        let snapshot = rows_address(&response.core.frozen_state);
831        assert!(
832            snapshot.is_some(),
833            "a full-state protocol must retain the caller's payload"
834        );
835        assert_ne!(
836            snapshot,
837            rows_address(&state),
838            "the response owns its snapshot rather than borrowing the caller's tree"
839        );
840
841        let mut committed = 0usize;
842        while !status.done {
843            status = match status.boundary.as_ref() {
844                Some(boundary) => {
845                    let next =
846                        response.resume(boundary.instance_id, &state, BoundaryMode::Final)?;
847                    committed += 1;
848                    assert!(
849                        next.boundary.is_none() && !next.done,
850                        "a commit step stops at its checkpoint and waits for advance"
851                    );
852                    assert_eq!(
853                        rows_address(&response.core.frozen_state),
854                        snapshot,
855                        "committing occurrence {committed} must not re-copy the retained snapshot"
856                    );
857                    next
858                }
859                None => response.advance()?,
860            };
861        }
862        assert_eq!(committed, 8, "every authored boundary must commit");
863
864        // The one-shot helper fuses commit and advance into one continuation
865        // setup, so its bytes must match the split steps it stands in for.
866        let mut helper_sink = TestSink {
867            output: String::new(),
868        };
869        handler.render_streaming(&protocol, &state, &render_options, &mut helper_sink)?;
870        assert_eq!(
871            helper_sink.output, sink.output,
872            "render_streaming must resume against the retained snapshot"
873        );
874        Ok(())
875    }
876
877    #[test]
878    fn fused_helper_matches_split_step_bytes_and_flushes() -> Result<()> {
879        // `render_streaming` skips the host hand-off between a commit and the
880        // parent bytes that follow it. That is a scheduling shortcut only: the
881        // emitted bytes and the positions at which they are flushed must match
882        // the split step machine exactly.
883        #[derive(Default)]
884        struct FlushSink {
885            output: String,
886            flushes: Vec<usize>,
887        }
888
889        impl ResponseWriter for FlushSink {
890            fn write(&mut self, content: &str) -> Result<()> {
891                self.output.push_str(content);
892                Ok(())
893            }
894
895            fn end(&mut self) -> Result<()> {
896                Ok(())
897            }
898        }
899
900        impl FlushWriter for FlushSink {
901            fn flush(&mut self) -> Result<()> {
902                self.flushes.push(self.output.len());
903                Ok(())
904            }
905        }
906
907        let protocol = boundary_protocol(4, StateProjectionMode::Keys);
908        let handler = WebUIHandler::new();
909        let state = test_json!({ "count": 3, "title": "fused" });
910        let render_options = options();
911
912        let mut split = FlushSink::default();
913        {
914            let mut response = handler.stream_response(&protocol, &render_options, &mut split)?;
915            let mut status = response.start(&state)?;
916            while !status.done {
917                status = match status.boundary.as_ref() {
918                    Some(boundary) => {
919                        response.resume(boundary.instance_id, &state, BoundaryMode::Final)?
920                    }
921                    None => response.advance()?,
922                };
923            }
924        }
925
926        let mut fused = FlushSink::default();
927        handler.render_streaming(&protocol, &state, &render_options, &mut fused)?;
928
929        assert_eq!(fused.output, split.output, "bytes must match");
930        assert_eq!(fused.flushes, split.flushes, "flush positions must match");
931        Ok(())
932    }
933
934    #[test]
935    fn resume_overlays_new_state_and_reuses_unchanged_subtrees() -> Result<()> {
936        // The public resume keeps its patch semantics: a changed key lands in
937        // the snapshot, an omitted key survives, and an unchanged subtree is
938        // left in place instead of being copied again.
939        let protocol = boundary_protocol(2, StateProjectionMode::All);
940        let handler = WebUIHandler::new();
941        let state = large_state(64);
942        let render_options = options();
943        let mut sink = TestSink {
944            output: String::new(),
945        };
946        let mut response = handler.stream_response(&protocol, &render_options, &mut sink)?;
947
948        let status = response.start(&state)?;
949        let snapshot = rows_address(&response.core.frozen_state);
950        let Some(boundary) = status.boundary.as_ref() else {
951            panic!("the first occurrence must suspend");
952        };
953
954        let mut next = state.clone();
955        if let Some(object) = next.as_object_mut() {
956            object.insert("title".to_string(), Value::String("second".to_string()));
957            object.remove("count");
958        }
959        let status = response.resume(boundary.instance_id, &next, BoundaryMode::Final)?;
960
961        assert_eq!(
962            response.core.frozen_state.get("title"),
963            Some(&Value::String("second".to_string())),
964            "a changed key must land in the snapshot"
965        );
966        assert_eq!(
967            response.core.frozen_state.get("count"),
968            Some(&test_json!(42)),
969            "an omitted key keeps the value the snapshot already holds"
970        );
971        assert_eq!(
972            rows_address(&response.core.frozen_state),
973            snapshot,
974            "an unchanged subtree must not be copied again"
975        );
976        assert!(
977            status.boundary.is_none() && !status.done,
978            "the commit step stops at its checkpoint"
979        );
980        assert!(
981            response.advance()?.boundary.is_some(),
982            "the second occurrence follows the parent bytes"
983        );
984        Ok(())
985    }
986
987    #[test]
988    fn semantic_steps_reuse_projection_scratch() -> Result<()> {
989        // The record projection scratch lives in the retained progress as plain
990        // integers, so after the first record every later step reuses the same
991        // allocation instead of rebuilding one per step.
992        let protocol = boundary_protocol(6, StateProjectionMode::Keys);
993        let handler = WebUIHandler::new();
994        let state = test_json!({ "count": 1, "title": "scratch", "unused": "x" });
995        let render_options = options();
996        let mut sink = TestSink {
997            output: String::new(),
998        };
999        let mut response = handler.stream_response(&protocol, &render_options, &mut sink)?;
1000
1001        let mut status = response.start(&state)?;
1002        let mut retained: Option<(usize, usize)> = None;
1003        while !status.done {
1004            let Some(boundary) = status.boundary.as_ref() else {
1005                status = response.advance()?;
1006                continue;
1007            };
1008            status = response.resume(boundary.instance_id, &state, BoundaryMode::Updatable)?;
1009            let Some(progress) = response.core.streaming.as_ref() else {
1010                panic!("a suspended response must retain its progress");
1011            };
1012            let observed = (
1013                progress.state_key_ids.capacity(),
1014                progress.state_key_ids.as_ptr().addr(),
1015            );
1016            assert!(
1017                observed.0 > 0,
1018                "the projection scratch must survive the record that filled it"
1019            );
1020            match retained {
1021                None => retained = Some(observed),
1022                Some(previous) => assert_eq!(
1023                    observed, previous,
1024                    "a later step must reuse the projection buffer, not allocate a new one"
1025                ),
1026            }
1027        }
1028        Ok(())
1029    }
1030
1031    #[test]
1032    fn updates_reuse_their_committed_projection_buffer() -> Result<()> {
1033        // An update writes through the plan captured at commit time: no key
1034        // list is rebuilt, so the plan's buffer keeps its identity across every
1035        // update it serves.
1036        let protocol = boundary_protocol(2, StateProjectionMode::Keys);
1037        let handler = WebUIHandler::new();
1038        let state = test_json!({ "count": 1, "title": "updates" });
1039        let render_options = options();
1040        let mut sink = TestSink {
1041            output: String::new(),
1042        };
1043        let mut response = handler.stream_response(&protocol, &render_options, &mut sink)?;
1044
1045        let status = response.start(&state)?;
1046        let Some(boundary) = status.boundary.as_ref() else {
1047            panic!("the first occurrence must suspend");
1048        };
1049        let instance_id = boundary.instance_id;
1050        response.resume(instance_id, &state, BoundaryMode::Updatable)?;
1051
1052        let mut retained: Option<(usize, usize)> = None;
1053        for _ in 0..4 {
1054            response.update(instance_id, &state)?;
1055            let Some(progress) = response.core.streaming.as_ref() else {
1056                panic!("a live response must retain its progress");
1057            };
1058            let Some(Some(plan)) = progress.update_plans.first() else {
1059                panic!("an updatable occurrence must retain its projection plan");
1060            };
1061            let observed = (plan.key_ids.capacity(), plan.key_ids.as_ptr().addr());
1062            assert!(!plan.requires_full_state, "keyed islands project keys");
1063            assert!(observed.0 > 0, "the plan must retain its key buffer");
1064            match retained {
1065                None => retained = Some(observed),
1066                Some(previous) => assert_eq!(
1067                    observed, previous,
1068                    "every update must reuse the committed projection buffer"
1069                ),
1070            }
1071        }
1072        assert_eq!(
1073            sink.output.matches(",2,0,{").count(),
1074            4,
1075            "each update emits exactly one typed state-update record"
1076        );
1077        Ok(())
1078    }
1079
1080    /// Drive `count` occurrences to completion in `mode`, returning the status
1081    /// the response stopped on.
1082    fn commit_occurrences<W: FlushWriter + ?Sized>(
1083        response: &mut StreamingResponse<'_, W>,
1084        status: StreamStatus,
1085        state: &Value,
1086        count: usize,
1087        mode: BoundaryMode,
1088    ) -> Result<StreamStatus> {
1089        let mut status = status;
1090        for committed in 0..count {
1091            let Some(boundary) = status.boundary.as_ref() else {
1092                panic!("occurrence {committed} must suspend before it can commit");
1093            };
1094            let instance_id = boundary.instance_id;
1095            response.resume(instance_id, state, mode)?;
1096            status = response.advance()?;
1097        }
1098        Ok(status)
1099    }
1100
1101    #[test]
1102    fn updatable_commits_stop_at_the_browser_retention_cap() -> Result<()> {
1103        // The browser retains every updatable occurrence for the life of the
1104        // response and refuses the one past its cap, so the server refuses to
1105        // emit that checkpoint at all. The refusal lands before a byte is
1106        // written and before the caller's state reaches the snapshot, leaving
1107        // the same occurrence pending so the host can commit it as final.
1108        let protocol = boundary_protocol(MAX_UPDATABLE_OCCURRENCES + 1, StateProjectionMode::Keys);
1109        let handler = WebUIHandler::new();
1110        let state = test_json!({ "count": 1, "title": "retained" });
1111        let render_options = options();
1112        let mut sink = SharedSink::default();
1113        let written = SharedSink::clone(&sink).0;
1114        let mut response = handler.stream_response(&protocol, &render_options, &mut sink)?;
1115
1116        let status = response.start(&state)?;
1117        let status = commit_occurrences(
1118            &mut response,
1119            status,
1120            &state,
1121            MAX_UPDATABLE_OCCURRENCES,
1122            BoundaryMode::Updatable,
1123        )?;
1124
1125        let Some(boundary) = status.boundary.as_ref() else {
1126            panic!("the occurrence past the cap must suspend like any other");
1127        };
1128        let instance_id = boundary.instance_id;
1129        let bytes_before = written.borrow().len();
1130        let refused = test_json!({ "count": 2, "title": "refused" });
1131        let rejected = response.resume(instance_id, &refused, BoundaryMode::Updatable);
1132        match rejected {
1133            Err(HandlerError::StreamingBoundary(error)) => {
1134                assert_eq!(error.signal, "resume");
1135                assert!(
1136                    error.reason.contains("BoundaryMode::Final"),
1137                    "the refusal must name the recovery: {}",
1138                    error.reason
1139                );
1140            }
1141            _ => panic!("committing past the cap must fail with a typed boundary error"),
1142        }
1143        assert_eq!(
1144            written.borrow().len(),
1145            bytes_before,
1146            "a refused commit must not write a byte"
1147        );
1148        assert_eq!(
1149            response.core.frozen_state.get("title"),
1150            Some(&Value::String("retained".to_string())),
1151            "a refused commit must not merge its state into the snapshot"
1152        );
1153
1154        // The occurrence is untouched, so the same ID commits as final.
1155        let status = response.resume(instance_id, &state, BoundaryMode::Final)?;
1156        assert!(
1157            status.boundary.is_none() && !status.done,
1158            "the retried commit stops at its own checkpoint"
1159        );
1160        assert!(
1161            written.borrow().len() > bytes_before,
1162            "the retry writes its checkpoint"
1163        );
1164        assert!(
1165            response.advance()?.done,
1166            "the response still reaches its terminal"
1167        );
1168        Ok(())
1169    }
1170
1171    #[test]
1172    fn final_commits_do_not_consume_the_updatable_cap() -> Result<()> {
1173        // Only occurrences the browser retains count against the cap: a final
1174        // boundary releases its roots at hydration, so a response may commit
1175        // any number of them and still use its full updatable budget.
1176        let protocol = boundary_protocol(MAX_UPDATABLE_OCCURRENCES + 2, StateProjectionMode::Keys);
1177        let handler = WebUIHandler::new();
1178        let state = test_json!({ "count": 1, "title": "mixed" });
1179        let render_options = options();
1180        let mut sink = TestSink {
1181            output: String::new(),
1182        };
1183        let mut response = handler.stream_response(&protocol, &render_options, &mut sink)?;
1184
1185        let status = response.start(&state)?;
1186        let status = commit_occurrences(&mut response, status, &state, 2, BoundaryMode::Final)?;
1187        let status = commit_occurrences(
1188            &mut response,
1189            status,
1190            &state,
1191            MAX_UPDATABLE_OCCURRENCES,
1192            BoundaryMode::Updatable,
1193        )?;
1194        assert!(
1195            status.done && response.is_done(),
1196            "final commits must leave the whole updatable budget available"
1197        );
1198        Ok(())
1199    }
1200
1201    #[test]
1202    fn owned_sessions_enforce_the_same_updatable_cap() -> Result<()> {
1203        // The owned session shares the borrowed session's continuation, so the
1204        // cap, the refusal, and the final-mode retry behave identically.
1205        let protocol = Arc::new(boundary_protocol(
1206            MAX_UPDATABLE_OCCURRENCES + 1,
1207            StateProjectionMode::Keys,
1208        ));
1209        let mut session = crate::streaming::StreamingSession::new(
1210            Arc::new(WebUIHandler::new()),
1211            protocol,
1212            crate::streaming::SessionOptions::new("index.html", "/"),
1213        )?;
1214        let state = test_json!({ "count": 1, "title": "owned" });
1215
1216        let mut step = session.start(&state)?;
1217        for committed in 0..MAX_UPDATABLE_OCCURRENCES {
1218            let Some(boundary) = step.boundary.as_ref() else {
1219                panic!("occurrence {committed} must suspend before it can commit");
1220            };
1221            let instance_id = boundary.instance_id;
1222            session.resume(instance_id, &state, BoundaryMode::Updatable)?;
1223            step = session.advance()?;
1224        }
1225
1226        let Some(boundary) = step.boundary.as_ref() else {
1227            panic!("the occurrence past the cap must suspend like any other");
1228        };
1229        let instance_id = boundary.instance_id;
1230        assert!(
1231            session
1232                .resume(instance_id, &state, BoundaryMode::Updatable)
1233                .is_err(),
1234            "an owned session refuses the occurrence past the cap"
1235        );
1236        let step = session.resume(instance_id, &state, BoundaryMode::Final)?;
1237        assert!(
1238            !step.bytes.is_empty(),
1239            "the retried commit still delivers its checkpoint bytes"
1240        );
1241        assert!(
1242            session.advance()?.done,
1243            "the owned response still reaches its terminal"
1244        );
1245        Ok(())
1246    }
1247}