Skip to main content

cubecl_server/stream/
capture.rs

1//! The stream-side graph-capture lifecycle, shared by every backend with
2//! graph support (see [`Server::graph_prepare`](crate::server::Server::graph_prepare)).
3
4use crate::metadata_cache::CacheMode;
5use crate::server::{BufferBinding, ServerError};
6use alloc::format;
7use alloc::vec::Vec;
8use cubecl_common::bytes::Bytes;
9use cubecl_environment::backtrace::BackTrace;
10use cubecl_environment::stream::StreamId;
11
12/// Where a stream sits in the graph-capture lifecycle, and the only thing
13/// allowed to move it. Capture is a strict `NoCapture → Prepare → Capture →
14/// NoCapture` progression, driven by [`prepare`](Self::prepare),
15/// [`begin`](Self::begin) and [`end`](Self::end); each rejects an out-of-order
16/// call, so a capture can never start unprepared and two captures can never
17/// overlap on one stream.
18///
19/// # One capture, one logical stream
20///
21/// The three calls have to come from the same logical stream. The window is
22/// opened on the pooled stream that logical stream folds onto, and the launches
23/// in between are recorded there — so a caller whose [`StreamId`] changes
24/// half-way (an `.await` resuming on another thread under the default
25/// `PerThread` policy, without `set_stream` pinning) has already split its
26/// recording across two backend streams before it ever reaches `end`. Pin the
27/// stream around a capture; [`end`](Self::end) treats a caller that is not the
28/// owner as a window nobody is coming back for, and abandons it.
29///
30/// The transitions live here rather than in each backend server because the
31/// rule is the same on every one of them — a backend supplies only the work a
32/// transition brackets (arming its pools, opening the driver's capture), never
33/// the ordering rule itself.
34///
35/// # What the neighbours pay
36///
37/// The window is held on a pooled stream, and logical streams fold onto those
38/// with `id % max_streams` — so a capture costs every logical stream sharing
39/// that slot, not just the one recording. On a software-graph backend a
40/// neighbour's read, sync or profile is refused outright for the duration, and
41/// its write is refused with the refusal landing on its own destinations; on a
42/// hardware-graph backend a
43/// neighbour's fenced flush is deferred until the window closes. None of that
44/// is attributed to the capture, because a refusal is not a failure of the
45/// capture: the neighbour asked for something this slot cannot do right now.
46///
47/// It is a real cost of folding, and the reason a capture is worth pinning to a
48/// stream nothing else is scheduled on.
49///
50/// Both active states carry the logical stream that opened the capture. Several
51/// logical streams share one backend stream, so "the capture owns this stream
52/// for its window" only holds if the window remembers whose it is: an error
53/// raised inside it dooms the capture, not whichever neighbour happens to be
54/// using the slot.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
56pub(crate) enum StreamCaptureState {
57    /// No capture is prepared or recording.
58    #[default]
59    NoCapture,
60    /// `graph_prepare` has armed the persistent pools for the warmup run;
61    /// `begin_capture` may now open the window. Slices the warmup run reserves
62    /// are retained by the memory manager's priming until `begin_capture` calls
63    /// [`capture_priming_end`](crate::memory_management::MemoryManagement::capture_priming_end),
64    /// so the pool ends up owning the capture run's full working set.
65    Prepare {
66        /// The logical stream that prepared the capture.
67        owner: StreamId,
68    },
69    /// Launches are being recorded into a graph instead of executing. On a
70    /// hardware-graph backend (CUDA, HIP) a host sync issued now aborts the
71    /// driver capture, so the execution path defers fenced flushes until
72    /// `end_capture`. A software-graph backend (wgpu) has no driver capture to
73    /// abort and instead refuses the operations it cannot record: a read, sync
74    /// or profile fails on the spot, while a write is rejected lazily — the
75    /// owner's own write dooms the recording so `end_capture` refuses to seal
76    /// it, since a graph missing an operation is worse than a late diagnostic.
77    Capture {
78        /// The logical stream recording the capture, which the errors raised
79        /// inside the window belong to.
80        owner: StreamId,
81    },
82}
83
84/// What [`StreamCapture::end`] found when it closed the window: the
85/// caller's own capture, or one belonging to a logical stream that never came
86/// back to close it.
87///
88/// Both close the window. Only the owner gets a graph out of it: the failures
89/// raised inside the window doom the recording, and sealing it for a caller
90/// that never saw them would hand back a graph silently missing whatever they
91/// rejected.
92///
93/// Refusing a foreign caller outright is the worse trade. Several logical
94/// streams share one pooled stream, and a window nobody closes rejects every
95/// read, write and sync that lands on the slot while recording launches into a
96/// graph no one can seal: the slot is lost for the life of the process. A
97/// foreign `end_capture` is a caller whose [`StreamId`] moved out from under it
98/// (see the type docs), which is exactly the case where the owner is gone — so
99/// the window is torn down and the failure reported, rather than kept for an
100/// owner that will never ask.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum CaptureEnd {
103    /// The caller opened this window: its recording may be sealed into a graph.
104    Owned {
105        /// The logical stream that opened the window, which is the caller.
106        owner: StreamId,
107    },
108    /// The window belonged to `owner`, not to the caller. It is closed, but
109    /// there is no graph to hand back: the backend tears the recording down and
110    /// reports, and a later `end_capture` from `owner` finds nothing recording.
111    Abandoned {
112        /// The logical stream that opened the window, which the report names
113        /// so the caller can see whose recording was discarded.
114        owner: StreamId,
115    },
116}
117
118impl CaptureEnd {
119    /// The logical stream the window belonged to.
120    pub fn owner(&self) -> StreamId {
121        match self {
122            CaptureEnd::Owned { owner } | CaptureEnd::Abandoned { owner } => *owner,
123        }
124    }
125
126    /// Whether the window was closed for a caller that did not own it, so its
127    /// recording is torn down instead of sealed.
128    pub fn is_abandoned(&self) -> bool {
129        matches!(self, CaptureEnd::Abandoned { .. })
130    }
131
132    /// The report a caller gets for closing a window it did not open: why the
133    /// recording was discarded, and then `doomed` — the failure that had
134    /// already sunk the recording, if one had, so the caller learns both
135    /// reasons rather than only the one that happened to be checked last.
136    ///
137    /// Only meaningful once [`is_abandoned`](Self::is_abandoned) says so; an
138    /// owned window is the caller's to seal and has nothing to report.
139    pub fn abandoned_error(&self, caller: StreamId, doomed: Option<ServerError>) -> ServerError {
140        let mut errors = alloc::vec![ServerError::graph_state(format!(
141            "end_capture: the capture belongs to logical stream {:?}, not to {caller:?}; it is \
142             discarded rather than left recording on a stream both share",
143            self.owner(),
144        ))];
145        errors.extend(doomed);
146
147        ServerError::Several {
148            errors,
149            backtrace: BackTrace::capture(),
150        }
151    }
152}
153
154/// The graph capture of one pooled backend stream: where it sits in the
155/// lifecycle, and the memory its recorded launches were given.
156///
157/// The two travel together because neither is meaningful alone. A launch is
158/// remembered only while the window is recording, and what it remembers is
159/// only ever read once the window closes — so pairing them
160/// makes "buffers accumulate inside a window and nowhere else" a property of
161/// the type rather than a rule each backend has to keep.
162///
163/// Why the memory is worth keeping: a replay runs every recorded launch or
164/// none, so a replay that fails to enqueue leaves all of them exactly as they
165/// were, and so does a capture that never seals. Either way a later read of one
166/// has to fail rather than copy out bytes nothing wrote — which needs the list
167/// the launches themselves no longer hold, as bindings, so the failure can be
168/// tainted onto the allocations they resolve to.
169#[derive(Debug, Default)]
170pub struct StreamCapture {
171    state: StreamCaptureState,
172    recorded: Vec<BufferBinding>,
173    /// The host memory the recorded copies read from, held while the window
174    /// is open and handed to the graph it seals into. A recorded memcpy node
175    /// keeps the raw host pointer, so the bytes must live exactly as long as
176    /// the graph — whatever kind of allocation they are, pinned-pool slice or
177    /// plain heap. A window that never seals drops them here: its copies
178    /// never ran and now never will.
179    retained_host: Vec<Bytes>,
180    /// The failure that doomed the window, if one did — work inside it failed
181    /// or was skipped, so the recording is missing an operation. Sealing it
182    /// would hand back a graph silently missing that work, and the replay
183    /// contract has the caller write fresh inputs before each replay,
184    /// clearing the very taint that would explain the hole — so the window is
185    /// doomed instead, and `end_capture` refuses to seal it.
186    failed: Option<ServerError>,
187}
188
189impl StreamCapture {
190    /// Remember the memory a launch was given, when the stream is recording.
191    ///
192    /// A no-op outside a window, where a launch that fails taints its own
193    /// buffers on the spot and there is no graph to answer for them later.
194    pub fn record(&mut self, buffers: impl IntoIterator<Item = BufferBinding>) {
195        if self.state.is_recording() {
196            self.recorded.extend(buffers);
197        }
198    }
199
200    /// The memory of the capture that just closed, each claim named once —
201    /// the same buffer comes back once per recorded launch that was given it.
202    ///
203    /// Deduplicated by [`claim_key`](BufferBinding::claim_key), because the
204    /// taint bookkeeping is range-exact and this list is what gets claimed
205    /// and released: two tensors carved from one batched allocation are two
206    /// claims, and collapsing them to their shared memory id would leave
207    /// every sibling but one unclaimed on a refusal and unreleased on a
208    /// replay.
209    pub fn take_recorded(&mut self) -> Vec<BufferBinding> {
210        let mut recorded = core::mem::take(&mut self.recorded);
211        recorded.sort_unstable_by_key(|binding| binding.claim_key());
212        recorded.dedup_by_key(|binding| binding.claim_key());
213        recorded
214    }
215
216    /// Doom the recording: work inside the window failed or was skipped —
217    /// see [`Self::take_failure`]. The first failure wins, and a stream that
218    /// is not recording has no window to doom.
219    pub fn fail(&mut self, error: ServerError) {
220        if self.state.is_recording() && self.failed.is_none() {
221            self.failed = Some(error);
222        }
223    }
224
225    /// Keep `bytes` alive for the graph this recording seals into: a
226    /// recorded copy holds their raw pointer and re-reads them on every
227    /// replay, so they must not return to any pool or allocator while the
228    /// graph lives. Handed to the graph by [`take_retained_host`](Self::take_retained_host).
229    ///
230    /// Only meaningful while recording — outside a window the bytes belong
231    /// in the drop queue, whose fence knows when the device is done with
232    /// them.
233    pub fn retain_host(&mut self, bytes: Bytes) {
234        debug_assert!(
235            self.state.is_recording(),
236            "host bytes are the window's to retain only while it records"
237        );
238        self.retained_host.push(bytes);
239    }
240
241    /// The host memory the window's recorded copies read from, taken as it
242    /// closes — onto the graph when it seals, or to be dropped when it does
243    /// not, since a recording that never becomes a graph never runs them.
244    pub fn take_retained_host(&mut self) -> Vec<Bytes> {
245        core::mem::take(&mut self.retained_host)
246    }
247
248    /// The failure that doomed this window, taken as it closes. `Some` means
249    /// the recording is missing at least one operation and must not seal.
250    pub fn take_failure(&mut self) -> Option<ServerError> {
251        self.failed.take()
252    }
253
254    /// Whether launches on the stream are being recorded into a graph right
255    /// now — the window during which a host sync would abort the capture, and
256    /// during which a neighbour sharing the pooled stream is refused.
257    pub fn is_recording(&self) -> bool {
258        self.state.is_recording()
259    }
260
261    /// Whether a capture is prepared or recording — the span over which the
262    /// pooled stream is committed to one logical stream's window.
263    pub fn is_active(&self) -> bool {
264        self.state.is_active()
265    }
266
267    /// The logical stream that opened the window, while one is open. `None`
268    /// outside a capture, which is what distinguishes a neighbour's operation
269    /// from the owner's.
270    pub fn owner(&self) -> Option<StreamId> {
271        self.state.owner()
272    }
273
274    /// How the metadata caches behave for this stream right now: a window
275    /// pins what it builds, so a replay finds the same entries it recorded
276    /// against.
277    pub fn cache_mode(&self) -> CacheMode {
278        self.state.cache_mode()
279    }
280
281    /// Arm the persistent pools for the warmup run; [`begin`](Self::begin)
282    /// may open the window afterwards. A capture starts from an empty
283    /// recording, so a window that was abandoned mid-flight cannot leak its
284    /// buffers into the next one.
285    ///
286    /// # Errors
287    ///
288    /// Fails when a capture is already prepared or recording, leaving both the
289    /// state and the recording untouched.
290    pub fn prepare(&mut self, owner: StreamId) -> Result<(), ServerError> {
291        self.state.prepare(owner)?;
292        self.recorded.clear();
293        self.retained_host.clear();
294        self.failed = None;
295        Ok(())
296    }
297
298    /// Open the window: launches from here until [`end`](Self::end) are
299    /// recorded rather than executed.
300    ///
301    /// # Errors
302    ///
303    /// Fails when no capture is prepared, or one is already recording.
304    pub fn begin(&mut self) -> Result<(), ServerError> {
305        self.state.begin()
306    }
307
308    /// Close the window, saying whether `caller` owned it — see
309    /// [`CaptureEnd`]. The recording survives the transition for
310    /// [`take_recorded`](Self::take_recorded) to collect.
311    ///
312    /// # Errors
313    ///
314    /// Fails when no capture is recording, leaving the state untouched.
315    pub fn end(&mut self, caller: StreamId) -> Result<CaptureEnd, ServerError> {
316        self.state.end(caller)
317    }
318
319    /// Give up a prepared capture that never opened, restoring the stream to
320    /// no-capture. Whatever it had recorded or retained goes with it.
321    pub fn abort(&mut self) {
322        self.state.abort();
323        self.recorded.clear();
324        self.retained_host.clear();
325        self.failed = None;
326    }
327}
328
329impl StreamCaptureState {
330    /// Whether launches on the stream are being recorded into a graph right
331    /// now — the window during which a host sync would abort (or is rejected
332    /// by) the capture.
333    pub(crate) fn is_recording(&self) -> bool {
334        matches!(self, StreamCaptureState::Capture { .. })
335    }
336
337    /// Whether a capture is prepared or recording — the whole window during
338    /// which the stream is not free to serve other work.
339    pub(crate) fn is_active(&self) -> bool {
340        !matches!(self, StreamCaptureState::NoCapture)
341    }
342
343    /// The logical stream this capture belongs to, `None` outside a window.
344    pub(crate) fn owner(&self) -> Option<StreamId> {
345        match self {
346            StreamCaptureState::NoCapture => None,
347            StreamCaptureState::Prepare { owner } | StreamCaptureState::Capture { owner } => {
348                Some(*owner)
349            }
350        }
351    }
352
353    /// The [`CacheMode`] the metadata info cache should run in at this lifecycle
354    /// position. Both while a graph is being *prepared* (warmup, which primes
355    /// the cache) and while it is being *recorded* the cache runs in
356    /// [`CacheMode::Capture`] — caching every buffer and invalidating none — so
357    /// the capture window finds every info buffer warm and drops none out from
358    /// under a recorded launch. Normal operation uses [`CacheMode::Normal`].
359    pub(crate) fn cache_mode(&self) -> CacheMode {
360        match self {
361            StreamCaptureState::NoCapture => CacheMode::Normal,
362            StreamCaptureState::Prepare { .. } | StreamCaptureState::Capture { .. } => {
363                CacheMode::Capture
364            }
365        }
366    }
367
368    /// `NoCapture → Prepare`, for `graph_prepare`. Call before arming the
369    /// pools; the caller owns the arming, this owns the rule that it happens
370    /// exactly once per capture.
371    ///
372    /// # Errors
373    ///
374    /// Fails when a capture is already prepared or already recording on this
375    /// stream, leaving the state untouched — two captures may never overlap on
376    /// one stream. The caller can retry after `end_capture`.
377    pub(crate) fn prepare(&mut self, owner: StreamId) -> Result<(), ServerError> {
378        match self {
379            StreamCaptureState::NoCapture => {
380                *self = StreamCaptureState::Prepare { owner };
381                Ok(())
382            }
383            StreamCaptureState::Prepare { .. } => Err(ServerError::graph_state(
384                "graph_prepare: a graph capture is already prepared on this stream",
385            )),
386            StreamCaptureState::Capture { .. } => Err(ServerError::graph_state(
387                "graph_prepare: a graph capture is already recording on this stream",
388            )),
389        }
390    }
391
392    /// `Prepare → Capture`, for `begin_capture`. Call *before* the work that
393    /// opens the window (ending the priming phase, starting the driver's
394    /// capture) so a rejected call cannot run any of it: on a stream that is
395    /// already recording, a drop-queue flush issued on the way to the rejection
396    /// would abort the live capture.
397    ///
398    /// Since the state moves before that work, a backend whose window fails to
399    /// open must undo it with [`abort`](Self::abort).
400    ///
401    /// # Errors
402    ///
403    /// Fails when [`prepare`](Self::prepare) has not run — the persistent pools
404    /// have to be primed by a warmup run first — or when a capture is already
405    /// recording. The state is left untouched.
406    pub(crate) fn begin(&mut self) -> Result<(), ServerError> {
407        match self {
408            StreamCaptureState::Prepare { owner } => {
409                *self = StreamCaptureState::Capture { owner: *owner };
410                Ok(())
411            }
412            StreamCaptureState::NoCapture => Err(ServerError::graph_state(
413                "begin_capture: call graph_prepare before starting a capture",
414            )),
415            StreamCaptureState::Capture { .. } => Err(ServerError::graph_state(
416                "begin_capture: a graph capture is already recording on this stream",
417            )),
418        }
419    }
420
421    /// `Capture → NoCapture`, for `end_capture`. Call before closing the
422    /// window, so the stream leaves capture state even if sealing the graph
423    /// then fails — a backend that returned an error with the state still set
424    /// would wedge the stream in capture mode forever.
425    ///
426    /// A caller that does not own the window closes it all the same, as
427    /// [`CaptureEnd::Abandoned`]: only the owner may *seal* a capture, but
428    /// leaving the window open until an owner that may never come back closes
429    /// it would wedge the pooled stream for every logical stream sharing it —
430    /// see [`CaptureEnd`] for why that is the lesser of the two.
431    ///
432    /// # Errors
433    ///
434    /// Fails when no capture is recording (nothing prepared or started, or the
435    /// capture already ended), leaving the state untouched — a stray
436    /// `end_capture` must not close a window that was never opened.
437    pub(crate) fn end(&mut self, caller: StreamId) -> Result<CaptureEnd, ServerError> {
438        match self {
439            StreamCaptureState::Capture { owner } => {
440                let owner = *owner;
441                *self = StreamCaptureState::NoCapture;
442                Ok(match owner == caller {
443                    true => CaptureEnd::Owned { owner },
444                    false => CaptureEnd::Abandoned { owner },
445                })
446            }
447            StreamCaptureState::NoCapture | StreamCaptureState::Prepare { .. } => {
448                Err(ServerError::graph_state(
449                    "end_capture: no graph capture is recording on this stream",
450                ))
451            }
452        }
453    }
454
455    /// Return to `NoCapture` from anywhere, for the failure path of a
456    /// transition's own work: the window never opened, so the stream must be
457    /// left fully usable and re-capturable rather than stuck arming its
458    /// persistent pools forever. Unlike [`end`](Self::end) this asserts
459    /// nothing, because the state it is recovering from is precisely the one
460    /// that could not be completed.
461    pub(crate) fn abort(&mut self) {
462        *self = StreamCaptureState::NoCapture;
463    }
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469    use crate::memory_management::ManagedMemoryId;
470    use crate::server::Handle;
471
472    /// A service for handles that never reach a device.
473    fn service() -> cubecl_common::device::ServiceId {
474        cubecl_common::device::ServiceId::of::<()>(cubecl_common::device::DeviceId::new(0, 0))
475    }
476
477    const OWNER: StreamId = StreamId { value: 7 };
478
479    /// A distinct buffer per call, on the owner's stream.
480    fn buffer() -> BufferBinding {
481        Handle::new(service(), OWNER, 8).binding()
482    }
483
484    fn ids(bindings: &[BufferBinding]) -> Vec<ManagedMemoryId> {
485        bindings.iter().map(|binding| binding.memory.id()).collect()
486    }
487
488    /// The ordering rule the three backends rely on: a capture cannot start
489    /// unprepared, and two cannot overlap on one stream. A backend that could
490    /// reach `Capture` without `Prepare` would record against pools no warmup
491    /// primed, and every allocation the window then makes is one the graph
492    /// replays against but nothing pins.
493    #[test]
494    fn transitions_follow_the_capture_order() {
495        let mut state = StreamCaptureState::NoCapture;
496
497        assert!(state.begin().is_err(), "a capture must be prepared first");
498        assert!(state.end(OWNER).is_err(), "nothing is recording yet");
499        assert_eq!(state, StreamCaptureState::NoCapture);
500
501        state.prepare(OWNER).unwrap();
502        assert_eq!(state, StreamCaptureState::Prepare { owner: OWNER });
503        assert!(state.prepare(OWNER).is_err(), "one prepare per capture");
504        assert!(state.end(OWNER).is_err(), "the window never opened");
505
506        state.begin().unwrap();
507        assert_eq!(state, StreamCaptureState::Capture { owner: OWNER });
508        assert!(state.begin().is_err(), "captures may not overlap");
509        assert!(state.prepare(OWNER).is_err(), "captures may not overlap");
510
511        assert_eq!(
512            state.end(OWNER).unwrap(),
513            CaptureEnd::Owned { owner: OWNER }
514        );
515        assert_eq!(state, StreamCaptureState::NoCapture);
516    }
517
518    /// The window remembers whose it is from end to end, so a failure raised
519    /// inside it dooms the capture that was recording rather than whichever
520    /// neighbour happens to be sharing the backend stream.
521    #[test]
522    fn the_window_carries_its_owner() {
523        let mut state = StreamCaptureState::NoCapture;
524        assert_eq!(state.owner(), None);
525
526        state.prepare(OWNER).unwrap();
527        assert_eq!(state.owner(), Some(OWNER));
528        assert!(state.is_active(), "the window is open from prepare on");
529
530        state.begin().unwrap();
531        assert_eq!(state.owner(), Some(OWNER));
532
533        assert_eq!(state.end(OWNER).unwrap().owner(), OWNER);
534        assert_eq!(state.owner(), None);
535        assert!(!state.is_active());
536    }
537
538    /// Only the stream that opened the window may seal it into a graph.
539    ///
540    /// A neighbour sealing it would hand back a recording built from a window
541    /// it never watched — the graph silently missing whatever the failures
542    /// raised inside it rejected.
543    #[test]
544    fn only_the_stream_that_opened_a_capture_may_seal_it() {
545        let neighbour = StreamId { value: 8 };
546
547        let mut state = StreamCaptureState::NoCapture;
548        state.prepare(OWNER).unwrap();
549        state.begin().unwrap();
550
551        assert_eq!(
552            state.end(neighbour).unwrap(),
553            CaptureEnd::Abandoned { owner: OWNER },
554            "the window is not theirs to seal"
555        );
556    }
557
558    /// A window its owner never closes must not hold the pooled stream, which
559    /// every logical stream folded onto the slot shares.
560    ///
561    /// The owner's id can stop coming back — the thread that started the
562    /// capture exits, or an `.await` resumes it elsewhere under `PerThread`. A
563    /// window kept until that id returns rejects every read, write and sync on
564    /// the slot forever, so a foreign `end` closes it and leaves the stream
565    /// usable, reporting rather than sealing.
566    #[test]
567    fn a_capture_no_one_can_close_does_not_wedge_the_stream() {
568        let neighbour = StreamId { value: 8 };
569
570        let mut state = StreamCaptureState::NoCapture;
571        state.prepare(OWNER).unwrap();
572        state.begin().unwrap();
573
574        assert!(state.end(neighbour).unwrap().is_abandoned());
575        assert_eq!(state, StreamCaptureState::NoCapture);
576        assert!(!state.is_active(), "the slot serves other work again");
577        state
578            .prepare(neighbour)
579            .expect("the stream is re-capturable");
580
581        // The owner coming back late finds nothing recording, rather than a
582        // window it can still seal a graph out of.
583        state.begin().unwrap();
584        assert!(
585            state.end(OWNER).unwrap().is_abandoned(),
586            "the window it opened is long gone"
587        );
588    }
589
590    /// A graph answers for every buffer its launches were given, and names each
591    /// one once however many launches shared it.
592    ///
593    /// A failed replay runs none of the recorded launches, so the list is what
594    /// a later read of any of those buffers fails on. Repeats would make that
595    /// list grow with the length of the capture rather than with the working
596    /// set — a chat step records hundreds of launches over the same handful of
597    /// weights.
598    #[test]
599    fn a_capture_names_each_buffer_its_launches_were_given_once() {
600        let (a, b, c) = (buffer(), buffer(), buffer());
601
602        let mut capture = StreamCapture::default();
603        capture.prepare(OWNER).unwrap();
604        capture.begin().unwrap();
605        capture.record([a.clone(), b.clone()]);
606        capture.record([b.clone(), c.clone()]);
607        capture.record([a.clone()]);
608
609        assert_eq!(
610            capture.end(OWNER).unwrap(),
611            CaptureEnd::Owned { owner: OWNER }
612        );
613        assert_eq!(ids(&capture.take_recorded()), ids(&[a, b, c]));
614        assert!(
615            capture.take_recorded().is_empty(),
616            "the recording moves onto the graph, it is not left on the stream"
617        );
618    }
619
620    /// Outside a window a launch answers for its own buffers as it fails, so
621    /// nothing is remembered for a graph that will never exist.
622    ///
623    /// A stream that accumulated ids while not recording would grow one entry
624    /// per launch for the life of the process, and hand the next capture a list
625    /// of buffers it never touched.
626    #[test]
627    fn a_launch_outside_a_window_is_not_recorded() {
628        let (before, warmup, recorded) = (buffer(), buffer(), buffer());
629        let mut capture = StreamCapture::default();
630
631        capture.record([before]);
632        capture.prepare(OWNER).unwrap();
633        // Prepared is the warmup run: it executes rather than records.
634        capture.record([warmup]);
635
636        capture.begin().unwrap();
637        capture.record([recorded.clone()]);
638        capture.end(OWNER).unwrap();
639
640        assert_eq!(ids(&capture.take_recorded()), ids(&[recorded]));
641    }
642
643    /// A batched allocation is carved into sibling tensors sharing one memory
644    /// id and nothing else, and the taint bookkeeping is range-exact — so the
645    /// write set keeps every range. Collapsed to the id, a refusal would
646    /// claim (and a replay release) only whichever sibling survived the
647    /// dedup, leaving the others readable with stale bytes or unreadable with
648    /// clean ones.
649    #[test]
650    fn a_capture_names_every_range_of_a_batched_allocation() {
651        let handle = Handle::new(service(), OWNER, 8);
652        let mut front = handle.clone().binding();
653        front.offset_end = Some(4);
654        let mut back = handle.clone().binding();
655        back.offset_start = Some(4);
656        assert_eq!(
657            front.memory.id(),
658            back.memory.id(),
659            "one allocation carved in two is the case under test"
660        );
661
662        let mut capture = StreamCapture::default();
663        capture.prepare(OWNER).unwrap();
664        capture.begin().unwrap();
665        capture.record([front.clone(), back.clone()]);
666        // The same range again, from a second launch given the same tensor:
667        // still one claim.
668        capture.record([front.clone()]);
669        capture.end(OWNER).unwrap();
670
671        let recorded = capture.take_recorded();
672        let keys: Vec<_> = recorded.iter().map(|binding| binding.claim_key()).collect();
673        assert_eq!(
674            keys,
675            alloc::vec![front.claim_key(), back.claim_key()],
676            "both siblings survive, each named once"
677        );
678    }
679
680    /// The host bytes a recorded copy reads from live with the window: taken
681    /// once as it closes — onto the graph, when one seals — and dropped with
682    /// an aborted window, whose copies never ran and now never will.
683    #[test]
684    fn a_window_owns_the_host_bytes_its_copies_read() {
685        let mut capture = StreamCapture::default();
686        capture.prepare(OWNER).unwrap();
687        capture.begin().unwrap();
688        capture.retain_host(Bytes::from_bytes_vec(alloc::vec![7u8; 4]));
689        capture.end(OWNER).unwrap();
690
691        assert_eq!(capture.take_retained_host().len(), 1);
692        assert!(
693            capture.take_retained_host().is_empty(),
694            "taken means moved onto the graph, not copied"
695        );
696
697        capture.prepare(OWNER).unwrap();
698        capture.begin().unwrap();
699        capture.retain_host(Bytes::from_bytes_vec(alloc::vec![7u8; 4]));
700        capture.abort();
701        capture.prepare(OWNER).unwrap();
702        assert!(
703            capture.take_retained_host().is_empty(),
704            "an aborted window keeps nothing alive"
705        );
706    }
707
708    /// A window that never sealed leaves nothing behind for the next one.
709    ///
710    /// The stream is re-capturable after an abort or an abandoned end, and a
711    /// second capture that inherited the first one's buffers would pin memory
712    /// its own launches never saw — failing reads that had nothing to do with
713    /// it, on a graph that outlives the mistake.
714    #[test]
715    fn a_new_capture_starts_from_an_empty_recording() {
716        let neighbour = StreamId { value: 8 };
717
718        let (aborted, abandoned) = (buffer(), buffer());
719        let mut capture = StreamCapture::default();
720        capture.prepare(OWNER).unwrap();
721        capture.begin().unwrap();
722        capture.record([aborted]);
723        capture.abort();
724
725        capture.prepare(OWNER).unwrap();
726        capture.begin().unwrap();
727        capture.record([abandoned.clone()]);
728        assert!(capture.end(neighbour).unwrap().is_abandoned());
729        assert_eq!(ids(&capture.take_recorded()), ids(&[abandoned]));
730
731        capture.prepare(neighbour).unwrap();
732        capture.begin().unwrap();
733        assert!(
734            capture.take_recorded().is_empty(),
735            "the abandoned window's buffers are not this capture's to answer for"
736        );
737    }
738
739    /// What a caller learns from closing a window that was not theirs: whose
740    /// it was, and whatever had already doomed the recording.
741    ///
742    /// The owner is the one piece of evidence the caller can act on — it names
743    /// the stream whose recording was thrown away. The doomed reason travels
744    /// with it because both are true at once, and reporting only the
745    /// abandonment would hide a failure that had already made the recording
746    /// unsealable.
747    #[test]
748    fn an_abandoned_window_reports_whose_it_was_and_what_doomed_it() {
749        let caller = StreamId { value: 8 };
750        let outcome = CaptureEnd::Abandoned { owner: OWNER };
751
752        let error = outcome.abandoned_error(caller, Some(ServerError::graph_state("doomed")));
753
754        let ServerError::Several { errors, .. } = &error else {
755            panic!("an abandoned window reports several failures at once, got: {error:?}");
756        };
757        let reported = alloc::format!("{error}");
758        assert!(
759            reported.contains(&alloc::format!("{OWNER:?}"))
760                && reported.contains(&alloc::format!("{caller:?}")),
761            "the report has to name the window's owner and the caller refused it, got: {reported}"
762        );
763        assert_eq!(errors.len(), 2, "the doomed reason travels with it");
764        assert!(
765            alloc::format!("{}", errors[1]).contains("doomed"),
766            "the explanation comes first, then what had already sunk it"
767        );
768    }
769
770    /// A rejected transition leaves the stream exactly as it was, so a caller
771    /// that miss orders a call can recover by issuing the right one — the
772    /// property `wgpu_graph_lifecycle_state_errors` defends end to end.
773    #[test]
774    fn a_rejected_transition_changes_nothing() {
775        let mut state = StreamCaptureState::Prepare { owner: OWNER };
776        assert!(state.prepare(OWNER).is_err());
777        assert_eq!(state, StreamCaptureState::Prepare { owner: OWNER });
778        state.begin().unwrap();
779    }
780
781    /// `abort` recovers from a window that failed to open, from either of the
782    /// states a backend can be holding when that happens.
783    #[test]
784    fn abort_recovers_a_window_that_never_opened() {
785        for state in [
786            StreamCaptureState::Prepare { owner: OWNER },
787            StreamCaptureState::Capture { owner: OWNER },
788        ] {
789            let mut state = state;
790            state.abort();
791            assert_eq!(state, StreamCaptureState::NoCapture);
792            state.prepare(OWNER).expect("the stream is re-capturable");
793        }
794    }
795
796    /// The cache runs in capture mode for the *whole* prepare → record window,
797    /// not just while recording: warmup is what makes the recorded launches hit
798    /// warm info buffers, and an entry evicted between the two would be one a
799    /// recorded launch dropped out from under itself.
800    #[test]
801    fn the_cache_captures_across_the_whole_window() {
802        assert_eq!(
803            StreamCaptureState::NoCapture.cache_mode(),
804            CacheMode::Normal
805        );
806        assert_eq!(
807            StreamCaptureState::Prepare { owner: OWNER }.cache_mode(),
808            CacheMode::Capture
809        );
810        assert_eq!(
811            StreamCaptureState::Capture { owner: OWNER }.cache_mode(),
812            CacheMode::Capture
813        );
814    }
815}