Skip to main content

player_plugin/scope/
playback.rs

1//! Finite playback and next-item prewarm scope coordination.
2
3use std::num::NonZeroU64;
4use std::time::{Duration, Instant};
5
6use thiserror::Error;
7
8use super::{
9    BusyChildPolicy, PluginRuntime, PluginScope, PluginScopeCloseReport, PluginScopeError,
10    PluginScopeKind, PluginScopeState, fair_share_deadline,
11};
12
13/// Maximum UTF-8 bytes accepted for one non-secret correlation identifier.
14///
15/// Correlations share the protocol resource-identity bound because they are
16/// intended for the same bounded diagnostic envelope.
17pub const MAX_PLUGIN_CORRELATION_ID_BYTES: usize = crate::MAX_PLUGIN_RESOURCE_IDENTITY_BYTES;
18
19/// Plan and sequence identity shared by active playback and next-item prewarm.
20#[derive(Debug, Clone, PartialEq, Eq, Hash)]
21pub struct PluginSessionCorrelation {
22    plan_fingerprint: String,
23    session_id: String,
24    session_generation: u64,
25}
26
27impl PluginSessionCorrelation {
28    pub fn new(
29        plan_fingerprint: impl Into<String>,
30        session_id: impl Into<String>,
31        session_generation: u64,
32    ) -> Result<Self, PluginPlaybackError> {
33        let plan_fingerprint = plan_fingerprint.into();
34        if !is_lowercase_sha256(&plan_fingerprint) {
35            return Err(PluginPlaybackError::InvalidPlanFingerprint);
36        }
37        let session_id = validate_correlation_id("session_id", session_id.into())?;
38        if session_generation == 0 {
39            return Err(PluginPlaybackError::ZeroCorrelationValue {
40                field: "session_generation",
41            });
42        }
43        Ok(Self {
44            plan_fingerprint,
45            session_id,
46            session_generation,
47        })
48    }
49
50    pub fn plan_fingerprint(&self) -> &str {
51        &self.plan_fingerprint
52    }
53
54    pub fn session_id(&self) -> &str {
55        &self.session_id
56    }
57
58    pub const fn session_generation(&self) -> u64 {
59        self.session_generation
60    }
61}
62
63/// Correlation carried by one active playback scope.
64#[derive(Debug, Clone, PartialEq, Eq, Hash)]
65pub struct PluginActivePlaybackCorrelation {
66    session: PluginSessionCorrelation,
67    item_id: String,
68    source_revision: u64,
69    playback_generation: u64,
70}
71
72impl PluginActivePlaybackCorrelation {
73    pub fn new(
74        session: PluginSessionCorrelation,
75        item_id: impl Into<String>,
76        source_revision: u64,
77        playback_generation: u64,
78    ) -> Result<Self, PluginPlaybackError> {
79        Ok(Self {
80            session,
81            item_id: validate_correlation_id("item_id", item_id.into())?,
82            source_revision: validate_non_zero_correlation("source_revision", source_revision)?,
83            playback_generation: validate_non_zero_correlation(
84                "playback_generation",
85                playback_generation,
86            )?,
87        })
88    }
89
90    pub fn session(&self) -> &PluginSessionCorrelation {
91        &self.session
92    }
93
94    pub fn item_id(&self) -> &str {
95        &self.item_id
96    }
97
98    pub const fn source_revision(&self) -> u64 {
99        self.source_revision
100    }
101
102    pub const fn playback_generation(&self) -> u64 {
103        self.playback_generation
104    }
105}
106
107/// Correlation carried by the one allowed next-item prewarm scope.
108#[derive(Debug, Clone, PartialEq, Eq, Hash)]
109pub struct PluginNextPrewarmCorrelation {
110    session: PluginSessionCorrelation,
111    item_id: String,
112    source_revision: u64,
113    warmup_task_id: u64,
114}
115
116impl PluginNextPrewarmCorrelation {
117    pub fn new(
118        session: PluginSessionCorrelation,
119        item_id: impl Into<String>,
120        source_revision: u64,
121        warmup_task_id: u64,
122    ) -> Result<Self, PluginPlaybackError> {
123        Ok(Self {
124            session,
125            item_id: validate_correlation_id("item_id", item_id.into())?,
126            source_revision: validate_non_zero_correlation("source_revision", source_revision)?,
127            warmup_task_id: validate_non_zero_correlation("warmup_task_id", warmup_task_id)?,
128        })
129    }
130
131    pub fn session(&self) -> &PluginSessionCorrelation {
132        &self.session
133    }
134
135    pub fn item_id(&self) -> &str {
136        &self.item_id
137    }
138
139    pub const fn source_revision(&self) -> u64 {
140        self.source_revision
141    }
142
143    pub const fn warmup_task_id(&self) -> u64 {
144        self.warmup_task_id
145    }
146}
147
148/// Runtime-local token that invalidates stale slot attachments.
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)]
150pub struct PluginPlaybackAttachmentToken(NonZeroU64);
151
152impl PluginPlaybackAttachmentToken {
153    pub const fn get(self) -> u64 {
154        self.0.get()
155    }
156}
157
158/// Authority that only the current active playback slot may exercise.
159#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)]
160pub enum PluginPlaybackAuthority {
161    MasterClock,
162    VideoSurface,
163    AudioSink,
164    Participation,
165}
166
167/// Finite role held by one managed playback attachment.
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)]
169pub enum PluginPlaybackRole {
170    Active,
171    NextPrewarm,
172}
173
174#[derive(Debug, Clone, PartialEq, Eq, Hash)]
175enum PluginPlaybackCorrelation {
176    Active(PluginActivePlaybackCorrelation),
177    NextPrewarm(PluginNextPrewarmCorrelation),
178}
179
180/// One runtime-local attachment to a managed metadata-only scope.
181#[derive(Clone)]
182pub struct PluginPlaybackAttachment {
183    token: PluginPlaybackAttachmentToken,
184    correlation: PluginPlaybackCorrelation,
185    scope: PluginScope,
186}
187
188impl PluginPlaybackAttachment {
189    pub const fn token(&self) -> PluginPlaybackAttachmentToken {
190        self.token
191    }
192
193    pub fn role(&self) -> PluginPlaybackRole {
194        match self.correlation {
195            PluginPlaybackCorrelation::Active(_) => PluginPlaybackRole::Active,
196            PluginPlaybackCorrelation::NextPrewarm(_) => PluginPlaybackRole::NextPrewarm,
197        }
198    }
199
200    pub fn session(&self) -> &PluginSessionCorrelation {
201        match &self.correlation {
202            PluginPlaybackCorrelation::Active(correlation) => correlation.session(),
203            PluginPlaybackCorrelation::NextPrewarm(correlation) => correlation.session(),
204        }
205    }
206
207    pub fn item_id(&self) -> &str {
208        match &self.correlation {
209            PluginPlaybackCorrelation::Active(correlation) => correlation.item_id(),
210            PluginPlaybackCorrelation::NextPrewarm(correlation) => correlation.item_id(),
211        }
212    }
213
214    pub const fn source_revision(&self) -> u64 {
215        match &self.correlation {
216            PluginPlaybackCorrelation::Active(correlation) => correlation.source_revision(),
217            PluginPlaybackCorrelation::NextPrewarm(correlation) => correlation.source_revision(),
218        }
219    }
220
221    pub fn scope(&self) -> PluginScope {
222        self.scope.clone()
223    }
224}
225
226/// Bounded settlement result from promoting or replacing active playback.
227#[derive(Clone)]
228pub struct PluginPlaybackTransitionReport {
229    pub active: PluginPlaybackAttachment,
230    pub previous_active: Option<PluginScopeCloseReport>,
231    pub discarded_next_prewarm: Option<PluginScopeCloseReport>,
232}
233
234#[derive(Debug, Error, Clone, PartialEq, Eq)]
235pub enum PluginPlaybackError {
236    #[error("plugin plan fingerprint must be one lowercase SHA-256 value")]
237    InvalidPlanFingerprint,
238    #[error("plugin correlation id `{field}` must contain 1 to {limit} UTF-8 bytes")]
239    InvalidCorrelationId { field: &'static str, limit: usize },
240    #[error("plugin correlation value `{field}` must be non-zero")]
241    ZeroCorrelationValue { field: &'static str },
242    #[error("plugin playback correlation does not match the runtime plan fingerprint")]
243    PlanFingerprintMismatch,
244    #[error("plugin playback correlation does not match the active session")]
245    SessionMismatch,
246    #[error("plugin playback session generation mismatch: expected {expected}, got {actual}")]
247    SessionGenerationMismatch { expected: u64, actual: u64 },
248    #[error("plugin playback item identity does not match the next prewarm slot")]
249    ItemMismatch,
250    #[error("plugin playback source revision mismatch: expected {expected}, got {actual}")]
251    SourceRevisionMismatch { expected: u64, actual: u64 },
252    #[error("plugin playback generation must advance beyond {previous}, got {actual}")]
253    PlaybackGenerationNotAdvanced { previous: u64, actual: u64 },
254    #[error("plugin active playback slot is already occupied")]
255    ActiveSlotOccupied,
256    #[error("plugin next-item prewarm slot is already occupied")]
257    NextPrewarmSlotOccupied,
258    #[error("plugin active playback slot is empty")]
259    ActiveSlotMissing,
260    #[error("plugin next-item prewarm slot is empty")]
261    NextPrewarmSlotMissing,
262    #[error("plugin playback slot transition is already in progress")]
263    TransitionBusy,
264    #[error("plugin playback attachment token space is exhausted")]
265    AttachmentTokenExhausted,
266    #[error("plugin runtime playback slots are shutting down")]
267    RuntimeShuttingDown,
268    #[error("plugin {role:?} attachment is stale")]
269    StaleAttachment { role: PluginPlaybackRole },
270    #[error("next-item prewarm cannot commit active authority {authority:?}")]
271    NextPrewarmCannotCommit { authority: PluginPlaybackAuthority },
272    #[error(transparent)]
273    Scope(#[from] PluginScopeError),
274}
275
276#[derive(Debug, Clone, Copy, PartialEq, Eq)]
277enum PluginPlaybackSlotsState {
278    Open,
279    Transitioning,
280    Shutdown,
281}
282
283pub(super) struct PluginPlaybackSlots {
284    state: PluginPlaybackSlotsState,
285    next_token: u64,
286    active: Option<PluginPlaybackAttachment>,
287    next_prewarm: Option<PluginPlaybackAttachment>,
288}
289
290impl Default for PluginPlaybackSlots {
291    fn default() -> Self {
292        Self {
293            state: PluginPlaybackSlotsState::Open,
294            next_token: 1,
295            active: None,
296            next_prewarm: None,
297        }
298    }
299}
300
301impl PluginRuntime {
302    /// Attaches the first active playback scope for this runtime.
303    pub fn attach_active_playback(
304        &self,
305        correlation: PluginActivePlaybackCorrelation,
306    ) -> Result<PluginPlaybackAttachment, PluginPlaybackError> {
307        self.validate_session(correlation.session())?;
308        let mut slots = self.lock_playback();
309        slots.ensure_open()?;
310        if slots.active.is_some() {
311            return Err(PluginPlaybackError::ActiveSlotOccupied);
312        }
313        let scope = self.create_started_scope(PluginScopeKind::Playback)?;
314        let attachment = slots.attachment(PluginPlaybackCorrelation::Active(correlation), scope)?;
315        slots.active = Some(attachment.clone());
316        Ok(attachment)
317    }
318
319    /// Attaches the only allowed next-item prewarm scope.
320    pub fn attach_next_prewarm(
321        &self,
322        correlation: PluginNextPrewarmCorrelation,
323    ) -> Result<PluginPlaybackAttachment, PluginPlaybackError> {
324        self.validate_session(correlation.session())?;
325        let mut slots = self.lock_playback();
326        slots.ensure_open()?;
327        let active = slots
328            .active
329            .as_ref()
330            .ok_or(PluginPlaybackError::ActiveSlotMissing)?;
331        ensure_same_session(active.session(), correlation.session())?;
332        if active.item_id() == correlation.item_id() {
333            return Err(PluginPlaybackError::ItemMismatch);
334        }
335        if slots.next_prewarm.is_some() {
336            return Err(PluginPlaybackError::NextPrewarmSlotOccupied);
337        }
338        let scope = self.create_started_scope(PluginScopeKind::NextPrewarm)?;
339        let attachment =
340            slots.attachment(PluginPlaybackCorrelation::NextPrewarm(correlation), scope)?;
341        slots.next_prewarm = Some(attachment.clone());
342        Ok(attachment)
343    }
344
345    /// Rejects active-only effects from prewarm or stale attachments.
346    pub fn authorize_playback_authority(
347        &self,
348        attachment: &PluginPlaybackAttachment,
349        authority: PluginPlaybackAuthority,
350    ) -> Result<(), PluginPlaybackError> {
351        self.validate_session(attachment.session())?;
352        let slots = self.lock_playback();
353        slots.ensure_open()?;
354        if slots.matches_active(attachment) {
355            if attachment.scope.state() == PluginScopeState::Running {
356                return Ok(());
357            }
358            return Err(PluginPlaybackError::StaleAttachment {
359                role: PluginPlaybackRole::Active,
360            });
361        }
362        if slots.matches_next(attachment) {
363            return Err(PluginPlaybackError::NextPrewarmCannotCommit { authority });
364        }
365        Err(PluginPlaybackError::StaleAttachment {
366            role: attachment.role(),
367        })
368    }
369
370    /// Promotes the current next prewarm after an authoritative activation.
371    pub fn promote_next_prewarm(
372        &self,
373        prewarm: &PluginPlaybackAttachment,
374        correlation: PluginActivePlaybackCorrelation,
375        timeout: Duration,
376    ) -> Result<PluginPlaybackTransitionReport, PluginPlaybackError> {
377        self.validate_session(correlation.session())?;
378        let deadline = Instant::now()
379            .checked_add(timeout)
380            .ok_or(PluginScopeError::InvalidCloseTimeout)?;
381        let (previous_active, next_prewarm) = {
382            let mut slots = self.lock_playback();
383            slots.ensure_open()?;
384            if !slots.matches_next(prewarm) {
385                return Err(PluginPlaybackError::StaleAttachment {
386                    role: PluginPlaybackRole::NextPrewarm,
387                });
388            }
389            let next_prewarm = slots
390                .next_prewarm
391                .as_ref()
392                .ok_or(PluginPlaybackError::NextPrewarmSlotMissing)?;
393            validate_promotion(next_prewarm, &correlation)?;
394            if let Some(active) = &slots.active {
395                ensure_generation_advanced(active, &correlation)?;
396            }
397            let previous_active = slots.active.take();
398            let next_prewarm = slots
399                .next_prewarm
400                .take()
401                .ok_or(PluginPlaybackError::NextPrewarmSlotMissing)?;
402            slots.state = PluginPlaybackSlotsState::Transitioning;
403            (previous_active, next_prewarm)
404        };
405
406        let previous_active_report = match previous_active
407            .as_ref()
408            .map(|active| {
409                active.scope.settle_until(
410                    PluginScopeState::Closed,
411                    None,
412                    deadline,
413                    BusyChildPolicy::Quarantine,
414                )
415            })
416            .transpose()
417        {
418            Ok(report) => report,
419            Err(error) => {
420                self.recover_playback_transition(previous_active.as_ref(), Some(&next_prewarm));
421                return Err(error.into());
422            }
423        };
424        if let Err(error) = self.root.transition_child_kind(
425            &next_prewarm.scope,
426            PluginScopeKind::NextPrewarm,
427            PluginScopeKind::Playback,
428        ) {
429            self.recover_playback_transition(previous_active.as_ref(), Some(&next_prewarm));
430            return Err(error.into());
431        }
432
433        let mut slots = self.lock_playback();
434        slots.finish_transition()?;
435        let active = slots.attachment(
436            PluginPlaybackCorrelation::Active(correlation),
437            next_prewarm.scope,
438        )?;
439        slots.active = Some(active.clone());
440        Ok(PluginPlaybackTransitionReport {
441            active,
442            previous_active: previous_active_report,
443            discarded_next_prewarm: None,
444        })
445    }
446
447    /// Replaces active playback and settles any obsolete next prewarm first.
448    pub fn replace_active_playback(
449        &self,
450        correlation: PluginActivePlaybackCorrelation,
451        timeout: Duration,
452    ) -> Result<PluginPlaybackTransitionReport, PluginPlaybackError> {
453        self.validate_session(correlation.session())?;
454        let deadline = Instant::now()
455            .checked_add(timeout)
456            .ok_or(PluginScopeError::InvalidCloseTimeout)?;
457        let (previous_active, discarded_next_prewarm) = {
458            let mut slots = self.lock_playback();
459            slots.ensure_open()?;
460            if let Some(active) = &slots.active {
461                ensure_generation_advanced(active, &correlation)?;
462            }
463            slots.state = PluginPlaybackSlotsState::Transitioning;
464            (slots.active.take(), slots.next_prewarm.take())
465        };
466
467        let pending =
468            usize::from(previous_active.is_some()) + usize::from(discarded_next_prewarm.is_some());
469        let discarded_next_prewarm_report = match discarded_next_prewarm
470            .as_ref()
471            .map(|prewarm| {
472                prewarm.scope.settle_until(
473                    PluginScopeState::Cancelled,
474                    None,
475                    fair_share_deadline(deadline, pending.max(1)),
476                    BusyChildPolicy::Quarantine,
477                )
478            })
479            .transpose()
480        {
481            Ok(report) => report,
482            Err(error) => {
483                self.recover_playback_transition(
484                    previous_active.as_ref(),
485                    discarded_next_prewarm.as_ref(),
486                );
487                return Err(error.into());
488            }
489        };
490        let previous_active_report = match previous_active
491            .as_ref()
492            .map(|active| {
493                active.scope.settle_until(
494                    PluginScopeState::Closed,
495                    None,
496                    deadline,
497                    BusyChildPolicy::Quarantine,
498                )
499            })
500            .transpose()
501        {
502            Ok(report) => report,
503            Err(error) => {
504                self.recover_playback_transition(
505                    previous_active.as_ref(),
506                    discarded_next_prewarm.as_ref(),
507                );
508                return Err(error.into());
509            }
510        };
511
512        let scope = match self.create_started_scope(PluginScopeKind::Playback) {
513            Ok(scope) => scope,
514            Err(error) => {
515                self.abort_playback_transition();
516                return Err(error);
517            }
518        };
519        let mut slots = self.lock_playback();
520        slots.finish_transition()?;
521        let active = slots.attachment(PluginPlaybackCorrelation::Active(correlation), scope)?;
522        slots.active = Some(active.clone());
523        Ok(PluginPlaybackTransitionReport {
524            active,
525            previous_active: previous_active_report,
526            discarded_next_prewarm: discarded_next_prewarm_report,
527        })
528    }
529
530    /// Cancels the exact next prewarm attachment and rejects stale callers.
531    pub fn cancel_next_prewarm(
532        &self,
533        prewarm: &PluginPlaybackAttachment,
534        timeout: Duration,
535    ) -> Result<PluginScopeCloseReport, PluginPlaybackError> {
536        let deadline = Instant::now()
537            .checked_add(timeout)
538            .ok_or(PluginScopeError::InvalidCloseTimeout)?;
539        let attachment = {
540            let mut slots = self.lock_playback();
541            slots.begin_transition()?;
542            if !slots.matches_next(prewarm) {
543                slots.state = PluginPlaybackSlotsState::Open;
544                return Err(PluginPlaybackError::StaleAttachment {
545                    role: PluginPlaybackRole::NextPrewarm,
546                });
547            }
548            slots
549                .next_prewarm
550                .take()
551                .ok_or(PluginPlaybackError::NextPrewarmSlotMissing)?
552        };
553        let report = match attachment.scope.settle_until(
554            PluginScopeState::Cancelled,
555            None,
556            deadline,
557            BusyChildPolicy::Quarantine,
558        ) {
559            Ok(report) => report,
560            Err(error) => {
561                self.recover_playback_transition(None, Some(&attachment));
562                return Err(error.into());
563            }
564        };
565        self.lock_playback().finish_transition()?;
566        Ok(report)
567    }
568
569    fn validate_session(
570        &self,
571        session: &PluginSessionCorrelation,
572    ) -> Result<(), PluginPlaybackError> {
573        if session.plan_fingerprint() != self.plan().fingerprint() {
574            return Err(PluginPlaybackError::PlanFingerprintMismatch);
575        }
576        Ok(())
577    }
578
579    fn create_started_scope(
580        &self,
581        kind: PluginScopeKind,
582    ) -> Result<PluginScope, PluginPlaybackError> {
583        match self.root.state() {
584            PluginScopeState::Created => self.root.start()?,
585            PluginScopeState::Running => {}
586            PluginScopeState::Starting | PluginScopeState::Draining => {
587                return Err(PluginPlaybackError::TransitionBusy);
588            }
589            PluginScopeState::Closed
590            | PluginScopeState::Failed
591            | PluginScopeState::Cancelled
592            | PluginScopeState::Quarantined => {
593                return Err(PluginPlaybackError::RuntimeShuttingDown);
594            }
595        }
596        let scope = self.root.create_child(kind)?;
597        if let Err(error) = scope.start() {
598            let _ = scope.close();
599            return Err(error.into());
600        }
601        Ok(scope)
602    }
603
604    fn lock_playback(&self) -> std::sync::MutexGuard<'_, PluginPlaybackSlots> {
605        self.playback
606            .lock()
607            .unwrap_or_else(|error| error.into_inner())
608    }
609
610    fn abort_playback_transition(&self) {
611        self.recover_playback_transition(None, None);
612    }
613
614    fn recover_playback_transition(
615        &self,
616        active: Option<&PluginPlaybackAttachment>,
617        next_prewarm: Option<&PluginPlaybackAttachment>,
618    ) {
619        let mut slots = self.lock_playback();
620        if slots.state == PluginPlaybackSlotsState::Transitioning {
621            if let Some(active) = active
622                && active.role() == PluginPlaybackRole::Active
623                && active.scope.state() == PluginScopeState::Running
624            {
625                slots.active = Some(active.clone());
626            }
627            if let Some(next_prewarm) = next_prewarm
628                && next_prewarm.role() == PluginPlaybackRole::NextPrewarm
629                && next_prewarm.scope.state() == PluginScopeState::Running
630            {
631                slots.next_prewarm = Some(next_prewarm.clone());
632            }
633            slots.state = PluginPlaybackSlotsState::Open;
634        }
635    }
636
637    pub(super) fn begin_playback_shutdown(&self) {
638        let mut slots = self.lock_playback();
639        slots.state = PluginPlaybackSlotsState::Shutdown;
640        slots.active = None;
641        slots.next_prewarm = None;
642    }
643}
644
645impl PluginPlaybackSlots {
646    fn ensure_open(&self) -> Result<(), PluginPlaybackError> {
647        match self.state {
648            PluginPlaybackSlotsState::Open => Ok(()),
649            PluginPlaybackSlotsState::Transitioning => Err(PluginPlaybackError::TransitionBusy),
650            PluginPlaybackSlotsState::Shutdown => Err(PluginPlaybackError::RuntimeShuttingDown),
651        }
652    }
653
654    fn begin_transition(&mut self) -> Result<(), PluginPlaybackError> {
655        self.ensure_open()?;
656        self.state = PluginPlaybackSlotsState::Transitioning;
657        Ok(())
658    }
659
660    fn finish_transition(&mut self) -> Result<(), PluginPlaybackError> {
661        match self.state {
662            PluginPlaybackSlotsState::Transitioning => {
663                self.state = PluginPlaybackSlotsState::Open;
664                Ok(())
665            }
666            PluginPlaybackSlotsState::Open => Err(PluginPlaybackError::TransitionBusy),
667            PluginPlaybackSlotsState::Shutdown => Err(PluginPlaybackError::RuntimeShuttingDown),
668        }
669    }
670
671    fn attachment(
672        &mut self,
673        correlation: PluginPlaybackCorrelation,
674        scope: PluginScope,
675    ) -> Result<PluginPlaybackAttachment, PluginPlaybackError> {
676        let raw = self.next_token;
677        self.next_token = self
678            .next_token
679            .checked_add(1)
680            .ok_or(PluginPlaybackError::AttachmentTokenExhausted)?;
681        let token = NonZeroU64::new(raw).ok_or(PluginPlaybackError::AttachmentTokenExhausted)?;
682        Ok(PluginPlaybackAttachment {
683            token: PluginPlaybackAttachmentToken(token),
684            correlation,
685            scope,
686        })
687    }
688
689    fn matches_active(&self, attachment: &PluginPlaybackAttachment) -> bool {
690        self.active
691            .as_ref()
692            .is_some_and(|active| same_attachment(active, attachment))
693    }
694
695    fn matches_next(&self, attachment: &PluginPlaybackAttachment) -> bool {
696        self.next_prewarm
697            .as_ref()
698            .is_some_and(|next| same_attachment(next, attachment))
699    }
700}
701
702fn same_attachment(left: &PluginPlaybackAttachment, right: &PluginPlaybackAttachment) -> bool {
703    left.token == right.token
704        && left.correlation == right.correlation
705        && left.scope.same_identity(&right.scope)
706}
707
708fn validate_promotion(
709    prewarm: &PluginPlaybackAttachment,
710    active: &PluginActivePlaybackCorrelation,
711) -> Result<(), PluginPlaybackError> {
712    ensure_same_session(prewarm.session(), active.session())?;
713    if prewarm.item_id() != active.item_id() {
714        return Err(PluginPlaybackError::ItemMismatch);
715    }
716    if prewarm.source_revision() != active.source_revision() {
717        return Err(PluginPlaybackError::SourceRevisionMismatch {
718            expected: prewarm.source_revision(),
719            actual: active.source_revision(),
720        });
721    }
722    Ok(())
723}
724
725fn ensure_generation_advanced(
726    previous: &PluginPlaybackAttachment,
727    next: &PluginActivePlaybackCorrelation,
728) -> Result<(), PluginPlaybackError> {
729    if previous.session().session_id() != next.session().session_id()
730        || previous.session().session_generation() != next.session().session_generation()
731    {
732        return Ok(());
733    }
734    let PluginPlaybackCorrelation::Active(previous) = &previous.correlation else {
735        return Ok(());
736    };
737    if next.playback_generation() <= previous.playback_generation() {
738        return Err(PluginPlaybackError::PlaybackGenerationNotAdvanced {
739            previous: previous.playback_generation(),
740            actual: next.playback_generation(),
741        });
742    }
743    Ok(())
744}
745
746fn ensure_same_session(
747    expected: &PluginSessionCorrelation,
748    actual: &PluginSessionCorrelation,
749) -> Result<(), PluginPlaybackError> {
750    if expected.plan_fingerprint() != actual.plan_fingerprint() {
751        return Err(PluginPlaybackError::PlanFingerprintMismatch);
752    }
753    if expected.session_id() != actual.session_id() {
754        return Err(PluginPlaybackError::SessionMismatch);
755    }
756    if expected.session_generation() != actual.session_generation() {
757        return Err(PluginPlaybackError::SessionGenerationMismatch {
758            expected: expected.session_generation(),
759            actual: actual.session_generation(),
760        });
761    }
762    Ok(())
763}
764
765fn validate_correlation_id(
766    field: &'static str,
767    value: String,
768) -> Result<String, PluginPlaybackError> {
769    if value.is_empty()
770        || value.len() > MAX_PLUGIN_CORRELATION_ID_BYTES
771        || value.chars().any(char::is_control)
772    {
773        return Err(PluginPlaybackError::InvalidCorrelationId {
774            field,
775            limit: MAX_PLUGIN_CORRELATION_ID_BYTES,
776        });
777    }
778    Ok(value)
779}
780
781fn validate_non_zero_correlation(
782    field: &'static str,
783    value: u64,
784) -> Result<u64, PluginPlaybackError> {
785    if value == 0 {
786        return Err(PluginPlaybackError::ZeroCorrelationValue { field });
787    }
788    Ok(value)
789}
790
791fn is_lowercase_sha256(value: &str) -> bool {
792    value.len() == 64
793        && value
794            .bytes()
795            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
796}