1use 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
13pub const MAX_PLUGIN_CORRELATION_ID_BYTES: usize = crate::MAX_PLUGIN_RESOURCE_IDENTITY_BYTES;
18
19#[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#[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#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)]
160pub enum PluginPlaybackAuthority {
161 MasterClock,
162 VideoSurface,
163 AudioSink,
164 Participation,
165}
166
167#[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#[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#[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 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 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 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 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 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 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}