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