1use std::collections::HashMap;
2use std::sync::{Arc, Mutex};
3use std::time::Duration;
4
5use tokio::sync::watch;
6
7use super::events::{
8 ProcessAwaitOutput, ProcessCompletionAuthority, ProcessEvent, ProcessEventAppendRequest,
9 ProcessEventAppendResult,
10};
11use super::model::{
12 AbandonRequest, ProcessChangeCursor, ProcessExternalRef, ProcessHandleDescriptor,
13 ProcessHandleGrant, ProcessHandleGrantEntry, ProcessLease, ProcessLeaseClaimOutcome,
14 ProcessLeaseCompletion, ProcessListFilter, ProcessRecord, ProcessRegistration,
15 ProcessSessionDeleteReport, ProcessStarted, SessionScope, WaitState,
16};
17use super::registry::{ProcessPruneReport, ProcessRegistry};
18use crate::PluginError;
19
20const AWAIT_BACKOFF_MIN: Duration = Duration::from_millis(25);
21const AWAIT_BACKOFF_MAX: Duration = Duration::from_secs(1);
22
23#[derive(Clone, Default)]
24pub struct ProcessChangeHub {
25 inner: Arc<Mutex<HashMap<String, watch::Sender<u64>>>>,
26}
27
28impl ProcessChangeHub {
29 pub fn new() -> Self {
30 Self::default()
31 }
32
33 pub fn subscribe(&self, process_id: &str) -> watch::Receiver<u64> {
36 let mut guard = self.inner.lock().expect("process change hub lock poisoned");
37 guard
38 .entry(process_id.to_string())
39 .or_insert_with(|| {
40 let (tx, _rx) = watch::channel(0);
41 tx
42 })
43 .subscribe()
44 }
45
46 pub fn notify(&self, process_id: &str) {
47 let mut guard = self.inner.lock().expect("process change hub lock poisoned");
48 let mut remove = false;
49 if let Some(tx) = guard.get(process_id) {
50 if tx.receiver_count() == 0 {
51 remove = true;
52 } else {
53 let next = (*tx.borrow()).wrapping_add(1);
54 if tx.send(next).is_err() {
55 remove = true;
56 }
57 }
58 }
59 if remove {
60 guard.remove(process_id);
61 }
62 }
63
64 #[cfg(test)]
65 fn tracked_processes(&self) -> usize {
66 self.inner
67 .lock()
68 .expect("process change hub lock poisoned")
69 .len()
70 }
71}
72
73#[async_trait::async_trait]
129pub trait ProcessEventSink: Send + Sync {
130 async fn emit(&self, event: &ProcessEvent);
134}
135
136struct WatchedProcessRegistry {
144 inner: Arc<dyn ProcessRegistry>,
145 hub: ProcessChangeHub,
146 sink: Option<Arc<dyn ProcessEventSink>>,
147}
148
149pub fn watch_process_registry(
155 inner: Arc<dyn ProcessRegistry>,
156) -> (Arc<dyn ProcessRegistry>, ProcessChangeHub) {
157 watch_process_registry_with_sink(inner, None)
158}
159
160pub fn watch_process_registry_with_sink(
165 inner: Arc<dyn ProcessRegistry>,
166 sink: Option<Arc<dyn ProcessEventSink>>,
167) -> (Arc<dyn ProcessRegistry>, ProcessChangeHub) {
168 let hub = ProcessChangeHub::new();
169 (
170 Arc::new(WatchedProcessRegistry {
171 inner,
172 hub: hub.clone(),
173 sink,
174 }),
175 hub,
176 )
177}
178
179#[derive(Clone)]
188pub struct ProcessAwaiter {
189 registry: Arc<dyn ProcessRegistry>,
190 hub: Option<ProcessChangeHub>,
191}
192
193impl ProcessAwaiter {
194 pub fn new(registry: Arc<dyn ProcessRegistry>, hub: ProcessChangeHub) -> Self {
198 Self {
199 registry,
200 hub: Some(hub),
201 }
202 }
203
204 pub fn polling(registry: Arc<dyn ProcessRegistry>) -> Self {
208 Self {
209 registry,
210 hub: None,
211 }
212 }
213
214 pub async fn await_terminal(
218 &self,
219 process_id: &str,
220 ) -> Result<ProcessAwaitOutput, PluginError> {
221 let mut backoff = AWAIT_BACKOFF_MIN;
222 if let Some(hub) = self.hub.as_ref() {
223 let mut rx = hub.subscribe(process_id);
224 loop {
225 if let Some(output) = self.read_terminal(process_id).await? {
226 return Ok(output);
227 }
228 tokio::select! {
229 changed = rx.changed() => {
230 match changed {
231 Ok(()) => backoff = AWAIT_BACKOFF_MIN,
232 Err(_) => break,
238 }
239 }
240 _ = tokio::time::sleep(backoff) => {
241 backoff = next_backoff(backoff);
242 }
243 }
244 }
245 }
246 loop {
247 if let Some(output) = self.read_terminal(process_id).await? {
248 return Ok(output);
249 }
250 tokio::time::sleep(backoff).await;
251 backoff = next_backoff(backoff);
252 }
253 }
254
255 pub async fn await_event(
258 &self,
259 process_id: &str,
260 event_type: &str,
261 after_sequence: u64,
262 ) -> Result<ProcessEvent, PluginError> {
263 let mut backoff = AWAIT_BACKOFF_MIN;
264 if let Some(hub) = self.hub.as_ref() {
265 let mut rx = hub.subscribe(process_id);
266 loop {
267 if let Some(event) = self
268 .read_event(process_id, event_type, after_sequence)
269 .await?
270 {
271 return Ok(event);
272 }
273 tokio::select! {
274 changed = rx.changed() => {
275 match changed {
276 Ok(()) => backoff = AWAIT_BACKOFF_MIN,
277 Err(_) => break,
283 }
284 }
285 _ = tokio::time::sleep(backoff) => {
286 backoff = next_backoff(backoff);
287 }
288 }
289 }
290 }
291 loop {
292 if let Some(event) = self
293 .read_event(process_id, event_type, after_sequence)
294 .await?
295 {
296 return Ok(event);
297 }
298 tokio::time::sleep(backoff).await;
299 backoff = next_backoff(backoff);
300 }
301 }
302
303 async fn read_terminal(
304 &self,
305 process_id: &str,
306 ) -> Result<Option<ProcessAwaitOutput>, PluginError> {
307 let record = self
308 .registry
309 .get_process(process_id)
310 .await
311 .ok_or_else(|| PluginError::Session(format!("unknown process `{process_id}`")))?;
312 Ok(record.status.await_output().cloned())
313 }
314
315 async fn read_event(
316 &self,
317 process_id: &str,
318 event_type: &str,
319 after_sequence: u64,
320 ) -> Result<Option<ProcessEvent>, PluginError> {
321 Ok(self
322 .registry
323 .events_after(process_id, after_sequence)
324 .await?
325 .into_iter()
326 .find(|event| event.event_type == event_type))
327 }
328}
329
330fn next_backoff(current: Duration) -> Duration {
331 current.saturating_mul(2).min(AWAIT_BACKOFF_MAX)
332}
333
334#[async_trait::async_trait]
335pub trait ProcessAttach: Send + Sync {
336 async fn await_terminal(&self, process_id: &str) -> Result<ProcessAwaitOutput, PluginError>;
337}
338
339#[async_trait::async_trait]
340impl ProcessRegistry for WatchedProcessRegistry {
341 fn durability_tier(&self) -> crate::DurabilityTier {
342 self.inner.durability_tier()
343 }
344
345 async fn register_process(
346 &self,
347 registration: ProcessRegistration,
348 ) -> Result<ProcessRecord, PluginError> {
349 let process_id = registration.id.clone();
350 let record = self.inner.register_process(registration).await?;
351 self.hub.notify(&process_id);
352 Ok(record)
353 }
354
355 async fn put_segment_handover(
356 &self,
357 process_id: &str,
358 handover: crate::PersistedSegmentHandover,
359 ) -> Result<(), PluginError> {
360 self.inner.put_segment_handover(process_id, handover).await
361 }
362
363 async fn get_segment_handover(
364 &self,
365 process_id: &str,
366 segment_ordinal: u64,
367 ) -> Result<Option<crate::PersistedSegmentHandover>, PluginError> {
368 self.inner
369 .get_segment_handover(process_id, segment_ordinal)
370 .await
371 }
372
373 async fn latest_segment_handover(
374 &self,
375 process_id: &str,
376 ) -> Result<Option<crate::PersistedSegmentHandover>, PluginError> {
377 self.inner.latest_segment_handover(process_id).await
378 }
379
380 async fn delete_segment_handovers(&self, process_id: &str) -> Result<(), PluginError> {
381 self.inner.delete_segment_handovers(process_id).await
382 }
383
384 async fn set_external_ref(
385 &self,
386 process_id: &str,
387 external_ref: ProcessExternalRef,
388 ) -> Result<ProcessRecord, PluginError> {
389 let record = self
390 .inner
391 .set_external_ref(process_id, external_ref)
392 .await?;
393 self.hub.notify(process_id);
394 Ok(record)
395 }
396
397 async fn grant_handle(
398 &self,
399 session_scope: &SessionScope,
400 process_id: &str,
401 descriptor: ProcessHandleDescriptor,
402 ) -> Result<ProcessHandleGrant, PluginError> {
403 self.inner
404 .grant_handle(session_scope, process_id, descriptor)
405 .await
406 }
407
408 async fn revoke_handle(
409 &self,
410 session_scope: &SessionScope,
411 process_id: &str,
412 ) -> Result<(), PluginError> {
413 self.inner.revoke_handle(session_scope, process_id).await
414 }
415
416 async fn transfer_handle_grants(
417 &self,
418 from_scope: &SessionScope,
419 to_scope: &SessionScope,
420 process_ids: &[String],
421 ) -> Result<(), PluginError> {
422 self.inner
423 .transfer_handle_grants(from_scope, to_scope, process_ids)
424 .await
425 }
426
427 async fn list_handle_grants(
428 &self,
429 session_scope: &SessionScope,
430 ) -> Result<Vec<ProcessHandleGrantEntry>, PluginError> {
431 self.inner.list_handle_grants(session_scope).await
432 }
433
434 async fn list_live_handle_grants(
435 &self,
436 session_scope: &SessionScope,
437 ) -> Result<Vec<ProcessHandleGrantEntry>, PluginError> {
438 self.inner.list_live_handle_grants(session_scope).await
439 }
440
441 async fn has_handle_grant(
442 &self,
443 session_scope: &SessionScope,
444 process_id: &str,
445 ) -> Result<bool, PluginError> {
446 self.inner.has_handle_grant(session_scope, process_id).await
447 }
448
449 async fn handle_grants_for_process(
450 &self,
451 process_id: &str,
452 ) -> Result<Vec<ProcessHandleGrant>, PluginError> {
453 self.inner.handle_grants_for_process(process_id).await
454 }
455
456 async fn delete_session_process_state(
457 &self,
458 session_id: &str,
459 ) -> Result<ProcessSessionDeleteReport, PluginError> {
460 self.inner.delete_session_process_state(session_id).await
461 }
462
463 async fn append_event(
464 &self,
465 process_id: &str,
466 request: ProcessEventAppendRequest,
467 ) -> Result<ProcessEventAppendResult, PluginError> {
468 let result = self.inner.append_event(process_id, request).await?;
469 self.hub.notify(process_id);
470 if let Some(sink) = self.sink.as_ref() {
474 sink.emit(&result.event).await;
475 }
476 Ok(result)
477 }
478
479 async fn events_after(
480 &self,
481 process_id: &str,
482 after_sequence: u64,
483 ) -> Result<Vec<ProcessEvent>, PluginError> {
484 self.inner.events_after(process_id, after_sequence).await
485 }
486
487 async fn count_events_through(
488 &self,
489 process_id: &str,
490 event_type: &str,
491 up_to_sequence: u64,
492 ) -> Result<u64, PluginError> {
493 self.inner
494 .count_events_through(process_id, event_type, up_to_sequence)
495 .await
496 }
497
498 async fn recent_events(
499 &self,
500 process_id: &str,
501 limit: usize,
502 ) -> Result<Vec<ProcessEvent>, PluginError> {
503 self.inner.recent_events(process_id, limit).await
504 }
505
506 async fn wake_events_after(
507 &self,
508 process_id: &str,
509 after_sequence: u64,
510 ) -> Result<Vec<ProcessEvent>, PluginError> {
511 self.inner
512 .wake_events_after(process_id, after_sequence)
513 .await
514 }
515
516 async fn complete_process(
517 &self,
518 process_id: &str,
519 await_output: ProcessAwaitOutput,
520 authority: ProcessCompletionAuthority,
521 ) -> Result<ProcessRecord, PluginError> {
522 let record = self
523 .inner
524 .complete_process(process_id, await_output, authority)
525 .await?;
526 self.hub.notify(process_id);
527 Ok(record)
528 }
529
530 async fn complete_process_with_lease(
531 &self,
532 lease: &ProcessLease,
533 await_output: ProcessAwaitOutput,
534 ) -> Result<ProcessRecord, PluginError> {
535 let record = self
536 .inner
537 .complete_process_with_lease(lease, await_output)
538 .await?;
539 self.hub.notify(&lease.process_id);
540 Ok(record)
541 }
542
543 async fn record_first_started(
544 &self,
545 process_id: &str,
546 started: ProcessStarted,
547 ) -> Result<ProcessRecord, PluginError> {
548 let record = self.inner.record_first_started(process_id, started).await?;
549 self.hub.notify(process_id);
550 Ok(record)
551 }
552
553 async fn request_process_abandon(
554 &self,
555 process_id: &str,
556 request: AbandonRequest,
557 ) -> Result<ProcessRecord, PluginError> {
558 let record = self
559 .inner
560 .request_process_abandon(process_id, request)
561 .await?;
562 self.hub.notify(process_id);
563 Ok(record)
564 }
565
566 async fn set_process_wait(
567 &self,
568 process_id: &str,
569 wait: WaitState,
570 ) -> Result<ProcessRecord, PluginError> {
571 let record = self.inner.set_process_wait(process_id, wait).await?;
572 self.hub.notify(process_id);
573 Ok(record)
574 }
575
576 async fn clear_process_wait(&self, process_id: &str) -> Result<ProcessRecord, PluginError> {
577 let record = self.inner.clear_process_wait(process_id).await?;
578 self.hub.notify(process_id);
579 Ok(record)
580 }
581
582 async fn get_process(&self, process_id: &str) -> Option<ProcessRecord> {
583 self.inner.get_process(process_id).await
584 }
585
586 async fn try_get_process(
587 &self,
588 process_id: &str,
589 ) -> Result<Option<ProcessRecord>, PluginError> {
590 self.inner.try_get_process(process_id).await
591 }
592
593 async fn list_processes(
594 &self,
595 filter: &ProcessListFilter,
596 ) -> Result<Vec<ProcessRecord>, PluginError> {
597 self.inner.list_processes(filter).await
598 }
599
600 async fn processes_changed_since(
601 &self,
602 cursor: ProcessChangeCursor,
603 limit: usize,
604 ) -> Result<(Vec<ProcessRecord>, ProcessChangeCursor), PluginError> {
605 self.inner.processes_changed_since(cursor, limit).await
606 }
607
608 async fn ack_wake(&self, process_id: &str, sequence: u64) -> Result<(), PluginError> {
609 self.inner.ack_wake(process_id, sequence).await?;
610 self.hub.notify(process_id);
611 Ok(())
612 }
613
614 async fn list_non_terminal(&self) -> Result<Vec<ProcessRecord>, PluginError> {
615 self.inner.list_non_terminal().await
616 }
617
618 async fn live_reference_summary(
619 &self,
620 ) -> Result<Vec<super::references::ProcessLiveReferenceSummary>, PluginError> {
621 self.inner.live_reference_summary().await
622 }
623
624 async fn claim_process_lease(
625 &self,
626 process_id: &str,
627 owner: &crate::LeaseOwnerIdentity,
628 lease_ttl_ms: u64,
629 ) -> Result<ProcessLeaseClaimOutcome, PluginError> {
630 self.inner
631 .claim_process_lease(process_id, owner, lease_ttl_ms)
632 .await
633 }
634
635 async fn reclaim_process_lease(
636 &self,
637 process_id: &str,
638 owner: &crate::LeaseOwnerIdentity,
639 observed_holder: &ProcessLease,
640 lease_ttl_ms: u64,
641 ) -> Result<ProcessLeaseClaimOutcome, PluginError> {
642 self.inner
643 .reclaim_process_lease(process_id, owner, observed_holder, lease_ttl_ms)
644 .await
645 }
646
647 async fn renew_process_lease(
648 &self,
649 lease: &ProcessLease,
650 lease_ttl_ms: u64,
651 ) -> Result<ProcessLease, PluginError> {
652 self.inner.renew_process_lease(lease, lease_ttl_ms).await
653 }
654
655 async fn get_process_lease(
656 &self,
657 process_id: &str,
658 ) -> Result<Option<ProcessLease>, PluginError> {
659 self.inner.get_process_lease(process_id).await
660 }
661
662 async fn complete_process_lease(
663 &self,
664 completion: &ProcessLeaseCompletion,
665 ) -> Result<(), PluginError> {
666 self.inner.complete_process_lease(completion).await
667 }
668
669 async fn prune_terminal_processes(
670 &self,
671 cutoff_epoch_ms: u64,
672 filter: Option<ProcessListFilter>,
673 up_to_change_seq: Option<ProcessChangeCursor>,
674 ) -> Result<ProcessPruneReport, PluginError> {
675 self.inner
678 .prune_terminal_processes(cutoff_epoch_ms, filter, up_to_change_seq)
679 .await
680 }
681}
682
683#[cfg(test)]
684mod tests {
685 use std::sync::Arc;
686
687 use super::*;
688 use crate::{
689 ProcessInput, ProcessProvenance, ProcessRegistration, TestLocalProcessRegistry, ToolControl,
690 };
691
692 fn registration(process_id: &str) -> ProcessRegistration {
693 ProcessRegistration::new(
694 process_id,
695 ProcessInput::External {
696 metadata: serde_json::json!({}),
697 },
698 crate::RecoveryDisposition::ExternallyOwned,
699 ProcessProvenance::host(),
700 )
701 }
702
703 fn plain_event_type(name: &str) -> crate::ProcessEventType {
704 crate::ProcessEventType {
705 name: name.to_string(),
706 payload_schema: crate::LashSchema::any(),
707 semantics: crate::ProcessEventSemanticsSpec::default(),
708 }
709 }
710
711 fn registration_with_events(process_id: &str, event_types: &[&str]) -> ProcessRegistration {
712 registration(process_id)
713 .with_extra_event_types(event_types.iter().map(|name| plain_event_type(name)))
714 }
715
716 #[derive(Clone, Default)]
718 struct CollectingSink {
719 events: Arc<Mutex<Vec<(String, u64)>>>,
720 }
721
722 impl CollectingSink {
723 fn collected(&self) -> Vec<(String, u64)> {
724 self.events.lock().expect("sink lock").clone()
725 }
726 }
727
728 #[async_trait::async_trait]
729 impl ProcessEventSink for CollectingSink {
730 async fn emit(&self, event: &ProcessEvent) {
731 self.events
732 .lock()
733 .expect("sink lock")
734 .push((event.event_type.clone(), event.sequence));
735 }
736 }
737
738 fn success(value: serde_json::Value) -> ProcessAwaitOutput {
739 ProcessAwaitOutput::Success {
740 value,
741 control: None::<ToolControl>,
742 }
743 }
744
745 #[test]
749 fn backoff_schedule_has_25ms_floor_doubling_to_1s_cap() {
750 assert_eq!(AWAIT_BACKOFF_MIN, Duration::from_millis(25));
751 assert_eq!(AWAIT_BACKOFF_MAX, Duration::from_secs(1));
752
753 let mut backoff = AWAIT_BACKOFF_MIN;
754 let mut schedule = vec![backoff];
755 while backoff < AWAIT_BACKOFF_MAX {
756 backoff = next_backoff(backoff);
757 schedule.push(backoff);
758 }
759 assert_eq!(
760 schedule,
761 [25, 50, 100, 200, 400, 800, 1000]
762 .into_iter()
763 .map(Duration::from_millis)
764 .collect::<Vec<_>>(),
765 "the backoff doubles from the 25ms floor and saturates at the 1s cap"
766 );
767 assert_eq!(
768 next_backoff(AWAIT_BACKOFF_MAX),
769 AWAIT_BACKOFF_MAX,
770 "the cap is absorbing"
771 );
772 }
773
774 #[tokio::test]
778 async fn prune_through_decorator_does_not_bump_the_hub() {
779 let raw = Arc::new(TestLocalProcessRegistry::default()) as Arc<dyn ProcessRegistry>;
780 let (registry, hub) = watch_process_registry(raw);
781 registry
782 .register_process(registration("proc-terminal"))
783 .await
784 .expect("register terminal");
785 registry
786 .complete_process(
787 "proc-terminal",
788 success(serde_json::json!("done")),
789 crate::ProcessCompletionAuthority::external_owner("test"),
790 )
791 .await
792 .expect("complete");
793 registry
794 .register_process(registration("proc-live"))
795 .await
796 .expect("register live");
797
798 let mut terminal_rx = hub.subscribe("proc-terminal");
801 let mut live_rx = hub.subscribe("proc-live");
802 terminal_rx.mark_unchanged();
803 live_rx.mark_unchanged();
804
805 let report = registry
806 .prune_terminal_processes(u64::MAX, None, None)
807 .await
808 .expect("prune");
809 assert_eq!(report.pruned_processes, 1, "the terminal process pruned");
810
811 assert!(
812 !terminal_rx.has_changed().expect("terminal sender open"),
813 "prune must not bump the pruned process's hub entry"
814 );
815 assert!(
816 !live_rx.has_changed().expect("live sender open"),
817 "prune must not bump surviving processes' hub entries"
818 );
819 }
820
821 #[tokio::test]
822 async fn hub_subscribe_then_notify_wakes_and_gc_drops_empty_entry() {
823 let hub = ProcessChangeHub::new();
824 let mut rx = hub.subscribe("proc");
825 hub.notify("proc");
826 tokio::time::timeout(Duration::from_millis(100), rx.changed())
827 .await
828 .expect("notify should wake")
829 .expect("sender remains open");
830
831 drop(rx);
832 hub.notify("proc");
833 assert_eq!(hub.tracked_processes(), 0);
834 }
835
836 #[tokio::test]
837 async fn await_event_returns_historical_event_immediately() {
838 let raw = Arc::new(TestLocalProcessRegistry::default()) as Arc<dyn ProcessRegistry>;
839 let (registry, hub) = watch_process_registry(raw);
840 registry
841 .register_process(registration("proc"))
842 .await
843 .expect("register");
844 let appended = registry
845 .append_event(
846 "proc",
847 ProcessEventAppendRequest::cancel_requested("proc", Some("stop".to_string())),
848 )
849 .await
850 .expect("append");
851
852 let event = ProcessAwaiter::new(Arc::clone(®istry), hub)
853 .await_event("proc", "process.cancel_requested", 0)
854 .await
855 .expect("await event");
856 assert_eq!(event.sequence, appended.event.sequence);
857 }
858
859 #[tokio::test]
860 async fn await_terminal_unknown_process_errors() {
861 let registry = Arc::new(TestLocalProcessRegistry::default()) as Arc<dyn ProcessRegistry>;
862 let err = ProcessAwaiter::polling(registry)
863 .await_terminal("missing")
864 .await
865 .expect_err("unknown process should error");
866 assert!(err.to_string().contains("unknown process `missing`"));
867 }
868
869 #[tokio::test]
870 async fn polling_awaiter_resolves_via_backoff() {
871 let registry = Arc::new(TestLocalProcessRegistry::default()) as Arc<dyn ProcessRegistry>;
872 registry
873 .register_process(registration("proc"))
874 .await
875 .expect("register");
876 let writer = Arc::clone(®istry);
877 tokio::spawn(async move {
878 tokio::time::sleep(Duration::from_millis(10)).await;
879 writer
880 .complete_process(
881 "proc",
882 success(serde_json::json!({ "ok": true })),
883 crate::ProcessCompletionAuthority::external_owner("test"),
884 )
885 .await
886 .expect("complete");
887 });
888
889 let output = tokio::time::timeout(
890 Duration::from_secs(1),
891 ProcessAwaiter::polling(registry).await_terminal("proc"),
892 )
893 .await
894 .expect("polling await timeout")
895 .expect("await terminal");
896 assert_eq!(output, success(serde_json::json!({ "ok": true })));
897 }
898
899 #[tokio::test]
900 async fn watched_awaiter_observes_terminal_without_lost_wakeup() {
901 let raw = Arc::new(TestLocalProcessRegistry::default()) as Arc<dyn ProcessRegistry>;
902 let (registry, hub) = watch_process_registry(raw);
903 registry
904 .register_process(registration("proc"))
905 .await
906 .expect("register");
907 let awaiter = ProcessAwaiter::new(Arc::clone(®istry), hub);
908 let waiter = tokio::spawn(async move { awaiter.await_terminal("proc").await });
909 registry
910 .complete_process(
911 "proc",
912 success(serde_json::json!("done")),
913 crate::ProcessCompletionAuthority::external_owner("test"),
914 )
915 .await
916 .expect("complete");
917
918 let output = tokio::time::timeout(Duration::from_millis(200), waiter)
919 .await
920 .expect("watched await timeout")
921 .expect("join")
922 .expect("await terminal");
923 assert_eq!(output, success(serde_json::json!("done")));
924 }
925
926 #[tokio::test]
927 async fn watched_registry_bumps_on_mutations() {
928 let raw = Arc::new(TestLocalProcessRegistry::default()) as Arc<dyn ProcessRegistry>;
929 let (registry, hub) = watch_process_registry(raw);
930 let mut rx = hub.subscribe("proc");
931 registry
932 .register_process(registration("proc"))
933 .await
934 .expect("register");
935 tokio::time::timeout(Duration::from_millis(100), rx.changed())
936 .await
937 .expect("register bump")
938 .expect("sender remains open");
939
940 registry
941 .append_event(
942 "proc",
943 ProcessEventAppendRequest::cancel_requested("proc", None),
944 )
945 .await
946 .expect("append");
947 tokio::time::timeout(Duration::from_millis(100), rx.changed())
948 .await
949 .expect("append bump")
950 .expect("sender remains open");
951 }
952
953 #[tokio::test]
954 async fn sink_receives_appended_events_in_order() {
955 let raw = Arc::new(TestLocalProcessRegistry::default()) as Arc<dyn ProcessRegistry>;
956 let sink = CollectingSink::default();
957 let (registry, _hub) = watch_process_registry_with_sink(raw, Some(Arc::new(sink.clone())));
958 registry
959 .register_process(registration_with_events(
960 "proc",
961 &["producer.a", "producer.b"],
962 ))
963 .await
964 .expect("register");
965 registry
966 .append_event(
967 "proc",
968 ProcessEventAppendRequest::new("producer.a", serde_json::json!({})),
969 )
970 .await
971 .expect("append a");
972 registry
973 .append_event(
974 "proc",
975 ProcessEventAppendRequest::new("producer.b", serde_json::json!({})),
976 )
977 .await
978 .expect("append b");
979
980 assert_eq!(
981 sink.collected(),
982 vec![("producer.a".to_string(), 1), ("producer.b".to_string(), 2)],
983 "the sink must observe appended events after their write, in append order"
984 );
985 }
986
987 #[tokio::test]
988 async fn sink_absent_leaves_appends_unchanged() {
989 let raw = Arc::new(TestLocalProcessRegistry::default()) as Arc<dyn ProcessRegistry>;
990 let (registry, _hub) = watch_process_registry_with_sink(raw, None);
991 registry
992 .register_process(registration_with_events("proc", &["producer.a"]))
993 .await
994 .expect("register");
995 let appended = registry
996 .append_event(
997 "proc",
998 ProcessEventAppendRequest::new("producer.a", serde_json::json!({})),
999 )
1000 .await
1001 .expect("append succeeds with no sink installed");
1002 assert_eq!(appended.event.sequence, 1);
1003 }
1004
1005 #[tokio::test]
1006 async fn sink_not_invoked_for_complete_process_terminal_append() {
1007 let raw = Arc::new(TestLocalProcessRegistry::default()) as Arc<dyn ProcessRegistry>;
1008 let sink = CollectingSink::default();
1009 let (registry, _hub) = watch_process_registry_with_sink(raw, Some(Arc::new(sink.clone())));
1010 registry
1011 .register_process(registration_with_events("proc", &["producer.a"]))
1012 .await
1013 .expect("register");
1014 registry
1015 .append_event(
1016 "proc",
1017 ProcessEventAppendRequest::new("producer.a", serde_json::json!({})),
1018 )
1019 .await
1020 .expect("explicit append");
1021 registry
1022 .complete_process(
1023 "proc",
1024 success(serde_json::json!("done")),
1025 crate::ProcessCompletionAuthority::external_owner("test"),
1026 )
1027 .await
1028 .expect("complete");
1029
1030 assert_eq!(
1031 sink.collected(),
1032 vec![("producer.a".to_string(), 1)],
1033 "complete_process appends its terminal event through the inner registry, so the \
1034 decorator never emits it to the sink"
1035 );
1036 }
1037
1038 #[tokio::test]
1039 async fn sink_present_still_bumps_hub_on_append() {
1040 let raw = Arc::new(TestLocalProcessRegistry::default()) as Arc<dyn ProcessRegistry>;
1041 let sink = CollectingSink::default();
1042 let (registry, hub) = watch_process_registry_with_sink(raw, Some(Arc::new(sink)));
1043 let mut rx = hub.subscribe("proc");
1044 registry
1045 .register_process(registration_with_events("proc", &["producer.a"]))
1046 .await
1047 .expect("register");
1048 tokio::time::timeout(Duration::from_millis(100), rx.changed())
1049 .await
1050 .expect("register bump")
1051 .expect("sender remains open");
1052 registry
1053 .append_event(
1054 "proc",
1055 ProcessEventAppendRequest::new("producer.a", serde_json::json!({})),
1056 )
1057 .await
1058 .expect("append");
1059 tokio::time::timeout(Duration::from_millis(100), rx.changed())
1060 .await
1061 .expect("append bump with a sink installed")
1062 .expect("sender remains open");
1063 }
1064
1065 struct NoopRunHandle;
1066
1067 #[async_trait::async_trait]
1068 impl crate::ProcessRunHandle for NoopRunHandle {
1069 async fn claim_and_run_pending(&self) -> Result<(), PluginError> {
1070 Ok(())
1071 }
1072 }
1073
1074 struct PanicAttach;
1075
1076 #[async_trait::async_trait]
1077 impl ProcessAttach for PanicAttach {
1078 async fn await_terminal(
1079 &self,
1080 _process_id: &str,
1081 ) -> Result<ProcessAwaitOutput, PluginError> {
1082 panic!("attach should not be called for already-terminal process")
1083 }
1084 }
1085
1086 struct ErrorAttach;
1087
1088 #[async_trait::async_trait]
1089 impl ProcessAttach for ErrorAttach {
1090 async fn await_terminal(
1091 &self,
1092 _process_id: &str,
1093 ) -> Result<ProcessAwaitOutput, PluginError> {
1094 Err(PluginError::Session("attach failed".to_string()))
1095 }
1096 }
1097
1098 #[tokio::test]
1099 async fn driver_short_circuits_terminal_before_attach() {
1100 let raw = Arc::new(TestLocalProcessRegistry::default()) as Arc<dyn ProcessRegistry>;
1101 let driver = crate::ProcessWorkDriver::new(raw, Arc::new(NoopRunHandle))
1102 .with_attach(Arc::new(PanicAttach));
1103 let registry = driver.process_registry();
1104 registry
1105 .register_process(registration("proc"))
1106 .await
1107 .expect("register");
1108 registry
1109 .complete_process(
1110 "proc",
1111 success(serde_json::json!("ready")),
1112 crate::ProcessCompletionAuthority::external_owner("test"),
1113 )
1114 .await
1115 .expect("complete");
1116
1117 let output = driver.await_terminal("proc").await.expect("await terminal");
1118 assert_eq!(output, success(serde_json::json!("ready")));
1119 }
1120
1121 #[tokio::test]
1122 async fn driver_attach_errors_propagate_without_poll_fallback() {
1123 let raw = Arc::new(TestLocalProcessRegistry::default()) as Arc<dyn ProcessRegistry>;
1124 let driver = crate::ProcessWorkDriver::new(raw, Arc::new(NoopRunHandle))
1125 .with_attach(Arc::new(ErrorAttach));
1126 driver
1127 .process_registry()
1128 .register_process(registration("proc"))
1129 .await
1130 .expect("register");
1131
1132 let err = driver
1133 .await_terminal("proc")
1134 .await
1135 .expect_err("attach error should propagate");
1136 assert!(err.to_string().contains("attach failed"));
1137 }
1138
1139 struct CountingAttach {
1140 calls: Arc<std::sync::atomic::AtomicUsize>,
1141 }
1142
1143 #[async_trait::async_trait]
1144 impl ProcessAttach for CountingAttach {
1145 async fn await_terminal(
1146 &self,
1147 _process_id: &str,
1148 ) -> Result<ProcessAwaitOutput, PluginError> {
1149 self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1150 Err(PluginError::Session(
1151 "attach must not be consulted for a terminal process".to_string(),
1152 ))
1153 }
1154 }
1155
1156 #[tokio::test]
1161 async fn concurrent_waiters_all_resolve_with_identical_output_on_completion() {
1162 let raw = Arc::new(TestLocalProcessRegistry::default()) as Arc<dyn ProcessRegistry>;
1163 let (registry, hub) = watch_process_registry(raw);
1164 registry
1165 .register_process(registration("proc"))
1166 .await
1167 .expect("register");
1168
1169 const WAITERS: usize = 16;
1170 let barrier = Arc::new(tokio::sync::Barrier::new(WAITERS + 1));
1171 let mut waiters = Vec::with_capacity(WAITERS);
1172 for _ in 0..WAITERS {
1173 let awaiter = ProcessAwaiter::new(Arc::clone(®istry), hub.clone());
1174 let barrier = Arc::clone(&barrier);
1175 waiters.push(tokio::spawn(async move {
1176 barrier.wait().await;
1177 awaiter.await_terminal("proc").await
1178 }));
1179 }
1180 barrier.wait().await;
1183 let output = success(serde_json::json!({ "raced": true }));
1184 registry
1185 .complete_process(
1186 "proc",
1187 output.clone(),
1188 crate::ProcessCompletionAuthority::external_owner("test"),
1189 )
1190 .await
1191 .expect("complete");
1192
1193 for waiter in waiters {
1194 let resolved = tokio::time::timeout(Duration::from_secs(2), waiter)
1195 .await
1196 .expect("each racing waiter resolves under 2s")
1197 .expect("join waiter")
1198 .expect("await terminal");
1199 assert_eq!(
1200 resolved, output,
1201 "every concurrent waiter resolves with identical terminal output"
1202 );
1203 }
1204 }
1205
1206 #[tokio::test]
1211 async fn driver_reattach_after_terminal_short_circuits_without_engine_call() {
1212 use std::sync::atomic::Ordering;
1213
1214 let raw = Arc::new(TestLocalProcessRegistry::default()) as Arc<dyn ProcessRegistry>;
1215 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1216 let driver = crate::ProcessWorkDriver::new(raw, Arc::new(NoopRunHandle)).with_attach(
1217 Arc::new(CountingAttach {
1218 calls: Arc::clone(&calls),
1219 }),
1220 );
1221 let registry = driver.process_registry();
1222 registry
1223 .register_process(registration("proc"))
1224 .await
1225 .expect("register");
1226 let output = success(serde_json::json!("reattached"));
1228 registry
1229 .complete_process(
1230 "proc",
1231 output.clone(),
1232 crate::ProcessCompletionAuthority::external_owner("test"),
1233 )
1234 .await
1235 .expect("complete");
1236
1237 let start = std::time::Instant::now();
1239 let resolved = driver.await_terminal("proc").await.expect("await terminal");
1240 assert_eq!(resolved, output);
1241 assert_eq!(
1242 calls.load(Ordering::SeqCst),
1243 0,
1244 "a terminal short-circuit must never call the engine attach"
1245 );
1246 assert!(
1247 start.elapsed() < Duration::from_millis(500),
1248 "a short-circuit resolves without any backoff wait"
1249 );
1250 }
1251
1252 #[derive(Clone, Default)]
1255 struct LossySink {
1256 seen: Arc<Mutex<Vec<u64>>>,
1257 dropped: Arc<Mutex<Vec<u64>>>,
1258 }
1259
1260 #[async_trait::async_trait]
1261 impl ProcessEventSink for LossySink {
1262 async fn emit(&self, event: &ProcessEvent) {
1263 if event.sequence.is_multiple_of(2) {
1264 self.dropped.lock().expect("sink lock").push(event.sequence);
1265 } else {
1266 self.seen.lock().expect("sink lock").push(event.sequence);
1267 }
1268 }
1269 }
1270
1271 #[tokio::test]
1276 async fn lossy_sink_still_reconciles_complete_log_from_events_after() {
1277 let raw = Arc::new(TestLocalProcessRegistry::default()) as Arc<dyn ProcessRegistry>;
1278 let sink = LossySink::default();
1279 let (registry, _hub) = watch_process_registry_with_sink(raw, Some(Arc::new(sink.clone())));
1280 registry
1281 .register_process(registration_with_events("proc", &["producer.step"]))
1282 .await
1283 .expect("register");
1284
1285 const EVENTS: u64 = 6;
1286 for _ in 0..EVENTS {
1287 registry
1288 .append_event(
1289 "proc",
1290 ProcessEventAppendRequest::new("producer.step", serde_json::json!({})),
1291 )
1292 .await
1293 .expect("append");
1294 }
1295 registry
1298 .complete_process(
1299 "proc",
1300 success(serde_json::json!("done")),
1301 crate::ProcessCompletionAuthority::external_owner("test"),
1302 )
1303 .await
1304 .expect("complete");
1305
1306 assert!(
1308 !sink.dropped.lock().expect("sink lock").is_empty(),
1309 "the lossy sink must drop at least one emit for the scenario to be meaningful"
1310 );
1311 assert!(
1312 (sink.seen.lock().expect("sink lock").len() as u64) < EVENTS,
1313 "the sink observed fewer events than were appended"
1314 );
1315 let reconciled = registry
1317 .events_after("proc", 0)
1318 .await
1319 .expect("events")
1320 .into_iter()
1321 .filter(|event| event.event_type == "producer.step")
1322 .map(|event| event.sequence)
1323 .collect::<Vec<_>>();
1324 assert_eq!(
1325 reconciled,
1326 (1..=EVENTS).collect::<Vec<_>>(),
1327 "events_after reconciles the complete non-terminal log despite push loss"
1328 );
1329 }
1330}