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 set_external_ref(
356 &self,
357 process_id: &str,
358 external_ref: ProcessExternalRef,
359 ) -> Result<ProcessRecord, PluginError> {
360 let record = self
361 .inner
362 .set_external_ref(process_id, external_ref)
363 .await?;
364 self.hub.notify(process_id);
365 Ok(record)
366 }
367
368 async fn grant_handle(
369 &self,
370 session_scope: &SessionScope,
371 process_id: &str,
372 descriptor: ProcessHandleDescriptor,
373 ) -> Result<ProcessHandleGrant, PluginError> {
374 self.inner
375 .grant_handle(session_scope, process_id, descriptor)
376 .await
377 }
378
379 async fn revoke_handle(
380 &self,
381 session_scope: &SessionScope,
382 process_id: &str,
383 ) -> Result<(), PluginError> {
384 self.inner.revoke_handle(session_scope, process_id).await
385 }
386
387 async fn transfer_handle_grants(
388 &self,
389 from_scope: &SessionScope,
390 to_scope: &SessionScope,
391 process_ids: &[String],
392 ) -> Result<(), PluginError> {
393 self.inner
394 .transfer_handle_grants(from_scope, to_scope, process_ids)
395 .await
396 }
397
398 async fn list_handle_grants(
399 &self,
400 session_scope: &SessionScope,
401 ) -> Result<Vec<ProcessHandleGrantEntry>, PluginError> {
402 self.inner.list_handle_grants(session_scope).await
403 }
404
405 async fn list_live_handle_grants(
406 &self,
407 session_scope: &SessionScope,
408 ) -> Result<Vec<ProcessHandleGrantEntry>, PluginError> {
409 self.inner.list_live_handle_grants(session_scope).await
410 }
411
412 async fn has_handle_grant(
413 &self,
414 session_scope: &SessionScope,
415 process_id: &str,
416 ) -> Result<bool, PluginError> {
417 self.inner.has_handle_grant(session_scope, process_id).await
418 }
419
420 async fn handle_grants_for_process(
421 &self,
422 process_id: &str,
423 ) -> Result<Vec<ProcessHandleGrant>, PluginError> {
424 self.inner.handle_grants_for_process(process_id).await
425 }
426
427 async fn delete_session_process_state(
428 &self,
429 session_id: &str,
430 ) -> Result<ProcessSessionDeleteReport, PluginError> {
431 self.inner.delete_session_process_state(session_id).await
432 }
433
434 async fn append_event(
435 &self,
436 process_id: &str,
437 request: ProcessEventAppendRequest,
438 ) -> Result<ProcessEventAppendResult, PluginError> {
439 let result = self.inner.append_event(process_id, request).await?;
440 self.hub.notify(process_id);
441 if let Some(sink) = self.sink.as_ref() {
445 sink.emit(&result.event).await;
446 }
447 Ok(result)
448 }
449
450 async fn events_after(
451 &self,
452 process_id: &str,
453 after_sequence: u64,
454 ) -> Result<Vec<ProcessEvent>, PluginError> {
455 self.inner.events_after(process_id, after_sequence).await
456 }
457
458 async fn count_events_through(
459 &self,
460 process_id: &str,
461 event_type: &str,
462 up_to_sequence: u64,
463 ) -> Result<u64, PluginError> {
464 self.inner
465 .count_events_through(process_id, event_type, up_to_sequence)
466 .await
467 }
468
469 async fn recent_events(
470 &self,
471 process_id: &str,
472 limit: usize,
473 ) -> Result<Vec<ProcessEvent>, PluginError> {
474 self.inner.recent_events(process_id, limit).await
475 }
476
477 async fn wake_events_after(
478 &self,
479 process_id: &str,
480 after_sequence: u64,
481 ) -> Result<Vec<ProcessEvent>, PluginError> {
482 self.inner
483 .wake_events_after(process_id, after_sequence)
484 .await
485 }
486
487 async fn complete_process(
488 &self,
489 process_id: &str,
490 await_output: ProcessAwaitOutput,
491 authority: ProcessCompletionAuthority,
492 ) -> Result<ProcessRecord, PluginError> {
493 let record = self
494 .inner
495 .complete_process(process_id, await_output, authority)
496 .await?;
497 self.hub.notify(process_id);
498 Ok(record)
499 }
500
501 async fn complete_process_with_lease(
502 &self,
503 lease: &ProcessLease,
504 await_output: ProcessAwaitOutput,
505 ) -> Result<ProcessRecord, PluginError> {
506 let record = self
507 .inner
508 .complete_process_with_lease(lease, await_output)
509 .await?;
510 self.hub.notify(&lease.process_id);
511 Ok(record)
512 }
513
514 async fn record_first_started(
515 &self,
516 process_id: &str,
517 started: ProcessStarted,
518 ) -> Result<ProcessRecord, PluginError> {
519 let record = self.inner.record_first_started(process_id, started).await?;
520 self.hub.notify(process_id);
521 Ok(record)
522 }
523
524 async fn request_process_abandon(
525 &self,
526 process_id: &str,
527 request: AbandonRequest,
528 ) -> Result<ProcessRecord, PluginError> {
529 let record = self
530 .inner
531 .request_process_abandon(process_id, request)
532 .await?;
533 self.hub.notify(process_id);
534 Ok(record)
535 }
536
537 async fn set_process_wait(
538 &self,
539 process_id: &str,
540 wait: WaitState,
541 ) -> Result<ProcessRecord, PluginError> {
542 let record = self.inner.set_process_wait(process_id, wait).await?;
543 self.hub.notify(process_id);
544 Ok(record)
545 }
546
547 async fn clear_process_wait(&self, process_id: &str) -> Result<ProcessRecord, PluginError> {
548 let record = self.inner.clear_process_wait(process_id).await?;
549 self.hub.notify(process_id);
550 Ok(record)
551 }
552
553 async fn get_process(&self, process_id: &str) -> Option<ProcessRecord> {
554 self.inner.get_process(process_id).await
555 }
556
557 async fn list_processes(
558 &self,
559 filter: &ProcessListFilter,
560 ) -> Result<Vec<ProcessRecord>, PluginError> {
561 self.inner.list_processes(filter).await
562 }
563
564 async fn processes_changed_since(
565 &self,
566 cursor: ProcessChangeCursor,
567 limit: usize,
568 ) -> Result<(Vec<ProcessRecord>, ProcessChangeCursor), PluginError> {
569 self.inner.processes_changed_since(cursor, limit).await
570 }
571
572 async fn ack_wake(&self, process_id: &str, sequence: u64) -> Result<(), PluginError> {
573 self.inner.ack_wake(process_id, sequence).await?;
574 self.hub.notify(process_id);
575 Ok(())
576 }
577
578 async fn list_non_terminal(&self) -> Result<Vec<ProcessRecord>, PluginError> {
579 self.inner.list_non_terminal().await
580 }
581
582 async fn live_reference_summary(
583 &self,
584 ) -> Result<Vec<super::references::ProcessLiveReferenceSummary>, PluginError> {
585 self.inner.live_reference_summary().await
586 }
587
588 async fn claim_process_lease(
589 &self,
590 process_id: &str,
591 owner: &crate::LeaseOwnerIdentity,
592 lease_ttl_ms: u64,
593 ) -> Result<ProcessLeaseClaimOutcome, PluginError> {
594 self.inner
595 .claim_process_lease(process_id, owner, lease_ttl_ms)
596 .await
597 }
598
599 async fn reclaim_process_lease(
600 &self,
601 process_id: &str,
602 owner: &crate::LeaseOwnerIdentity,
603 observed_holder: &ProcessLease,
604 lease_ttl_ms: u64,
605 ) -> Result<ProcessLeaseClaimOutcome, PluginError> {
606 self.inner
607 .reclaim_process_lease(process_id, owner, observed_holder, lease_ttl_ms)
608 .await
609 }
610
611 async fn renew_process_lease(
612 &self,
613 lease: &ProcessLease,
614 lease_ttl_ms: u64,
615 ) -> Result<ProcessLease, PluginError> {
616 self.inner.renew_process_lease(lease, lease_ttl_ms).await
617 }
618
619 async fn get_process_lease(
620 &self,
621 process_id: &str,
622 ) -> Result<Option<ProcessLease>, PluginError> {
623 self.inner.get_process_lease(process_id).await
624 }
625
626 async fn complete_process_lease(
627 &self,
628 completion: &ProcessLeaseCompletion,
629 ) -> Result<(), PluginError> {
630 self.inner.complete_process_lease(completion).await
631 }
632
633 async fn prune_terminal_processes(
634 &self,
635 cutoff_epoch_ms: u64,
636 filter: Option<ProcessListFilter>,
637 up_to_change_seq: Option<ProcessChangeCursor>,
638 ) -> Result<ProcessPruneReport, PluginError> {
639 self.inner
642 .prune_terminal_processes(cutoff_epoch_ms, filter, up_to_change_seq)
643 .await
644 }
645}
646
647#[cfg(test)]
648mod tests {
649 use std::sync::Arc;
650
651 use super::*;
652 use crate::{
653 ProcessInput, ProcessProvenance, ProcessRegistration, TestLocalProcessRegistry, ToolControl,
654 };
655
656 fn registration(process_id: &str) -> ProcessRegistration {
657 ProcessRegistration::new(
658 process_id,
659 ProcessInput::External {
660 metadata: serde_json::json!({}),
661 },
662 crate::RecoveryDisposition::ExternallyOwned,
663 ProcessProvenance::host(),
664 )
665 }
666
667 fn plain_event_type(name: &str) -> crate::ProcessEventType {
668 crate::ProcessEventType {
669 name: name.to_string(),
670 payload_schema: crate::LashSchema::any(),
671 semantics: crate::ProcessEventSemanticsSpec::default(),
672 }
673 }
674
675 fn registration_with_events(process_id: &str, event_types: &[&str]) -> ProcessRegistration {
676 registration(process_id)
677 .with_extra_event_types(event_types.iter().map(|name| plain_event_type(name)))
678 }
679
680 #[derive(Clone, Default)]
682 struct CollectingSink {
683 events: Arc<Mutex<Vec<(String, u64)>>>,
684 }
685
686 impl CollectingSink {
687 fn collected(&self) -> Vec<(String, u64)> {
688 self.events.lock().expect("sink lock").clone()
689 }
690 }
691
692 #[async_trait::async_trait]
693 impl ProcessEventSink for CollectingSink {
694 async fn emit(&self, event: &ProcessEvent) {
695 self.events
696 .lock()
697 .expect("sink lock")
698 .push((event.event_type.clone(), event.sequence));
699 }
700 }
701
702 fn success(value: serde_json::Value) -> ProcessAwaitOutput {
703 ProcessAwaitOutput::Success {
704 value,
705 control: None::<ToolControl>,
706 }
707 }
708
709 #[test]
713 fn backoff_schedule_has_25ms_floor_doubling_to_1s_cap() {
714 assert_eq!(AWAIT_BACKOFF_MIN, Duration::from_millis(25));
715 assert_eq!(AWAIT_BACKOFF_MAX, Duration::from_secs(1));
716
717 let mut backoff = AWAIT_BACKOFF_MIN;
718 let mut schedule = vec![backoff];
719 while backoff < AWAIT_BACKOFF_MAX {
720 backoff = next_backoff(backoff);
721 schedule.push(backoff);
722 }
723 assert_eq!(
724 schedule,
725 [25, 50, 100, 200, 400, 800, 1000]
726 .into_iter()
727 .map(Duration::from_millis)
728 .collect::<Vec<_>>(),
729 "the backoff doubles from the 25ms floor and saturates at the 1s cap"
730 );
731 assert_eq!(
732 next_backoff(AWAIT_BACKOFF_MAX),
733 AWAIT_BACKOFF_MAX,
734 "the cap is absorbing"
735 );
736 }
737
738 #[tokio::test]
742 async fn prune_through_decorator_does_not_bump_the_hub() {
743 let raw = Arc::new(TestLocalProcessRegistry::default()) as Arc<dyn ProcessRegistry>;
744 let (registry, hub) = watch_process_registry(raw);
745 registry
746 .register_process(registration("proc-terminal"))
747 .await
748 .expect("register terminal");
749 registry
750 .complete_process(
751 "proc-terminal",
752 success(serde_json::json!("done")),
753 crate::ProcessCompletionAuthority::external_owner("test"),
754 )
755 .await
756 .expect("complete");
757 registry
758 .register_process(registration("proc-live"))
759 .await
760 .expect("register live");
761
762 let mut terminal_rx = hub.subscribe("proc-terminal");
765 let mut live_rx = hub.subscribe("proc-live");
766 terminal_rx.mark_unchanged();
767 live_rx.mark_unchanged();
768
769 let report = registry
770 .prune_terminal_processes(u64::MAX, None, None)
771 .await
772 .expect("prune");
773 assert_eq!(report.pruned_processes, 1, "the terminal process pruned");
774
775 assert!(
776 !terminal_rx.has_changed().expect("terminal sender open"),
777 "prune must not bump the pruned process's hub entry"
778 );
779 assert!(
780 !live_rx.has_changed().expect("live sender open"),
781 "prune must not bump surviving processes' hub entries"
782 );
783 }
784
785 #[tokio::test]
786 async fn hub_subscribe_then_notify_wakes_and_gc_drops_empty_entry() {
787 let hub = ProcessChangeHub::new();
788 let mut rx = hub.subscribe("proc");
789 hub.notify("proc");
790 tokio::time::timeout(Duration::from_millis(100), rx.changed())
791 .await
792 .expect("notify should wake")
793 .expect("sender remains open");
794
795 drop(rx);
796 hub.notify("proc");
797 assert_eq!(hub.tracked_processes(), 0);
798 }
799
800 #[tokio::test]
801 async fn await_event_returns_historical_event_immediately() {
802 let raw = Arc::new(TestLocalProcessRegistry::default()) as Arc<dyn ProcessRegistry>;
803 let (registry, hub) = watch_process_registry(raw);
804 registry
805 .register_process(registration("proc"))
806 .await
807 .expect("register");
808 let appended = registry
809 .append_event(
810 "proc",
811 ProcessEventAppendRequest::cancel_requested("proc", Some("stop".to_string())),
812 )
813 .await
814 .expect("append");
815
816 let event = ProcessAwaiter::new(Arc::clone(®istry), hub)
817 .await_event("proc", "process.cancel_requested", 0)
818 .await
819 .expect("await event");
820 assert_eq!(event.sequence, appended.event.sequence);
821 }
822
823 #[tokio::test]
824 async fn await_terminal_unknown_process_errors() {
825 let registry = Arc::new(TestLocalProcessRegistry::default()) as Arc<dyn ProcessRegistry>;
826 let err = ProcessAwaiter::polling(registry)
827 .await_terminal("missing")
828 .await
829 .expect_err("unknown process should error");
830 assert!(err.to_string().contains("unknown process `missing`"));
831 }
832
833 #[tokio::test]
834 async fn polling_awaiter_resolves_via_backoff() {
835 let registry = Arc::new(TestLocalProcessRegistry::default()) as Arc<dyn ProcessRegistry>;
836 registry
837 .register_process(registration("proc"))
838 .await
839 .expect("register");
840 let writer = Arc::clone(®istry);
841 tokio::spawn(async move {
842 tokio::time::sleep(Duration::from_millis(10)).await;
843 writer
844 .complete_process(
845 "proc",
846 success(serde_json::json!({ "ok": true })),
847 crate::ProcessCompletionAuthority::external_owner("test"),
848 )
849 .await
850 .expect("complete");
851 });
852
853 let output = tokio::time::timeout(
854 Duration::from_secs(1),
855 ProcessAwaiter::polling(registry).await_terminal("proc"),
856 )
857 .await
858 .expect("polling await timeout")
859 .expect("await terminal");
860 assert_eq!(output, success(serde_json::json!({ "ok": true })));
861 }
862
863 #[tokio::test]
864 async fn watched_awaiter_observes_terminal_without_lost_wakeup() {
865 let raw = Arc::new(TestLocalProcessRegistry::default()) as Arc<dyn ProcessRegistry>;
866 let (registry, hub) = watch_process_registry(raw);
867 registry
868 .register_process(registration("proc"))
869 .await
870 .expect("register");
871 let awaiter = ProcessAwaiter::new(Arc::clone(®istry), hub);
872 let waiter = tokio::spawn(async move { awaiter.await_terminal("proc").await });
873 registry
874 .complete_process(
875 "proc",
876 success(serde_json::json!("done")),
877 crate::ProcessCompletionAuthority::external_owner("test"),
878 )
879 .await
880 .expect("complete");
881
882 let output = tokio::time::timeout(Duration::from_millis(200), waiter)
883 .await
884 .expect("watched await timeout")
885 .expect("join")
886 .expect("await terminal");
887 assert_eq!(output, success(serde_json::json!("done")));
888 }
889
890 #[tokio::test]
891 async fn watched_registry_bumps_on_mutations() {
892 let raw = Arc::new(TestLocalProcessRegistry::default()) as Arc<dyn ProcessRegistry>;
893 let (registry, hub) = watch_process_registry(raw);
894 let mut rx = hub.subscribe("proc");
895 registry
896 .register_process(registration("proc"))
897 .await
898 .expect("register");
899 tokio::time::timeout(Duration::from_millis(100), rx.changed())
900 .await
901 .expect("register bump")
902 .expect("sender remains open");
903
904 registry
905 .append_event(
906 "proc",
907 ProcessEventAppendRequest::cancel_requested("proc", None),
908 )
909 .await
910 .expect("append");
911 tokio::time::timeout(Duration::from_millis(100), rx.changed())
912 .await
913 .expect("append bump")
914 .expect("sender remains open");
915 }
916
917 #[tokio::test]
918 async fn sink_receives_appended_events_in_order() {
919 let raw = Arc::new(TestLocalProcessRegistry::default()) as Arc<dyn ProcessRegistry>;
920 let sink = CollectingSink::default();
921 let (registry, _hub) = watch_process_registry_with_sink(raw, Some(Arc::new(sink.clone())));
922 registry
923 .register_process(registration_with_events(
924 "proc",
925 &["producer.a", "producer.b"],
926 ))
927 .await
928 .expect("register");
929 registry
930 .append_event(
931 "proc",
932 ProcessEventAppendRequest::new("producer.a", serde_json::json!({})),
933 )
934 .await
935 .expect("append a");
936 registry
937 .append_event(
938 "proc",
939 ProcessEventAppendRequest::new("producer.b", serde_json::json!({})),
940 )
941 .await
942 .expect("append b");
943
944 assert_eq!(
945 sink.collected(),
946 vec![("producer.a".to_string(), 1), ("producer.b".to_string(), 2)],
947 "the sink must observe appended events after their write, in append order"
948 );
949 }
950
951 #[tokio::test]
952 async fn sink_absent_leaves_appends_unchanged() {
953 let raw = Arc::new(TestLocalProcessRegistry::default()) as Arc<dyn ProcessRegistry>;
954 let (registry, _hub) = watch_process_registry_with_sink(raw, None);
955 registry
956 .register_process(registration_with_events("proc", &["producer.a"]))
957 .await
958 .expect("register");
959 let appended = registry
960 .append_event(
961 "proc",
962 ProcessEventAppendRequest::new("producer.a", serde_json::json!({})),
963 )
964 .await
965 .expect("append succeeds with no sink installed");
966 assert_eq!(appended.event.sequence, 1);
967 }
968
969 #[tokio::test]
970 async fn sink_not_invoked_for_complete_process_terminal_append() {
971 let raw = Arc::new(TestLocalProcessRegistry::default()) as Arc<dyn ProcessRegistry>;
972 let sink = CollectingSink::default();
973 let (registry, _hub) = watch_process_registry_with_sink(raw, Some(Arc::new(sink.clone())));
974 registry
975 .register_process(registration_with_events("proc", &["producer.a"]))
976 .await
977 .expect("register");
978 registry
979 .append_event(
980 "proc",
981 ProcessEventAppendRequest::new("producer.a", serde_json::json!({})),
982 )
983 .await
984 .expect("explicit append");
985 registry
986 .complete_process(
987 "proc",
988 success(serde_json::json!("done")),
989 crate::ProcessCompletionAuthority::external_owner("test"),
990 )
991 .await
992 .expect("complete");
993
994 assert_eq!(
995 sink.collected(),
996 vec![("producer.a".to_string(), 1)],
997 "complete_process appends its terminal event through the inner registry, so the \
998 decorator never emits it to the sink"
999 );
1000 }
1001
1002 #[tokio::test]
1003 async fn sink_present_still_bumps_hub_on_append() {
1004 let raw = Arc::new(TestLocalProcessRegistry::default()) as Arc<dyn ProcessRegistry>;
1005 let sink = CollectingSink::default();
1006 let (registry, hub) = watch_process_registry_with_sink(raw, Some(Arc::new(sink)));
1007 let mut rx = hub.subscribe("proc");
1008 registry
1009 .register_process(registration_with_events("proc", &["producer.a"]))
1010 .await
1011 .expect("register");
1012 tokio::time::timeout(Duration::from_millis(100), rx.changed())
1013 .await
1014 .expect("register bump")
1015 .expect("sender remains open");
1016 registry
1017 .append_event(
1018 "proc",
1019 ProcessEventAppendRequest::new("producer.a", serde_json::json!({})),
1020 )
1021 .await
1022 .expect("append");
1023 tokio::time::timeout(Duration::from_millis(100), rx.changed())
1024 .await
1025 .expect("append bump with a sink installed")
1026 .expect("sender remains open");
1027 }
1028
1029 struct NoopRunHandle;
1030
1031 #[async_trait::async_trait]
1032 impl crate::ProcessRunHandle for NoopRunHandle {
1033 async fn claim_and_run_pending(&self) -> Result<(), PluginError> {
1034 Ok(())
1035 }
1036 }
1037
1038 struct PanicAttach;
1039
1040 #[async_trait::async_trait]
1041 impl ProcessAttach for PanicAttach {
1042 async fn await_terminal(
1043 &self,
1044 _process_id: &str,
1045 ) -> Result<ProcessAwaitOutput, PluginError> {
1046 panic!("attach should not be called for already-terminal process")
1047 }
1048 }
1049
1050 struct ErrorAttach;
1051
1052 #[async_trait::async_trait]
1053 impl ProcessAttach for ErrorAttach {
1054 async fn await_terminal(
1055 &self,
1056 _process_id: &str,
1057 ) -> Result<ProcessAwaitOutput, PluginError> {
1058 Err(PluginError::Session("attach failed".to_string()))
1059 }
1060 }
1061
1062 #[tokio::test]
1063 async fn driver_short_circuits_terminal_before_attach() {
1064 let raw = Arc::new(TestLocalProcessRegistry::default()) as Arc<dyn ProcessRegistry>;
1065 let driver = crate::ProcessWorkDriver::new(raw, Arc::new(NoopRunHandle))
1066 .with_attach(Arc::new(PanicAttach));
1067 let registry = driver.process_registry();
1068 registry
1069 .register_process(registration("proc"))
1070 .await
1071 .expect("register");
1072 registry
1073 .complete_process(
1074 "proc",
1075 success(serde_json::json!("ready")),
1076 crate::ProcessCompletionAuthority::external_owner("test"),
1077 )
1078 .await
1079 .expect("complete");
1080
1081 let output = driver.await_terminal("proc").await.expect("await terminal");
1082 assert_eq!(output, success(serde_json::json!("ready")));
1083 }
1084
1085 #[tokio::test]
1086 async fn driver_attach_errors_propagate_without_poll_fallback() {
1087 let raw = Arc::new(TestLocalProcessRegistry::default()) as Arc<dyn ProcessRegistry>;
1088 let driver = crate::ProcessWorkDriver::new(raw, Arc::new(NoopRunHandle))
1089 .with_attach(Arc::new(ErrorAttach));
1090 driver
1091 .process_registry()
1092 .register_process(registration("proc"))
1093 .await
1094 .expect("register");
1095
1096 let err = driver
1097 .await_terminal("proc")
1098 .await
1099 .expect_err("attach error should propagate");
1100 assert!(err.to_string().contains("attach failed"));
1101 }
1102
1103 struct CountingAttach {
1104 calls: Arc<std::sync::atomic::AtomicUsize>,
1105 }
1106
1107 #[async_trait::async_trait]
1108 impl ProcessAttach for CountingAttach {
1109 async fn await_terminal(
1110 &self,
1111 _process_id: &str,
1112 ) -> Result<ProcessAwaitOutput, PluginError> {
1113 self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1114 Err(PluginError::Session(
1115 "attach must not be consulted for a terminal process".to_string(),
1116 ))
1117 }
1118 }
1119
1120 #[tokio::test]
1125 async fn concurrent_waiters_all_resolve_with_identical_output_on_completion() {
1126 let raw = Arc::new(TestLocalProcessRegistry::default()) as Arc<dyn ProcessRegistry>;
1127 let (registry, hub) = watch_process_registry(raw);
1128 registry
1129 .register_process(registration("proc"))
1130 .await
1131 .expect("register");
1132
1133 const WAITERS: usize = 16;
1134 let barrier = Arc::new(tokio::sync::Barrier::new(WAITERS + 1));
1135 let mut waiters = Vec::with_capacity(WAITERS);
1136 for _ in 0..WAITERS {
1137 let awaiter = ProcessAwaiter::new(Arc::clone(®istry), hub.clone());
1138 let barrier = Arc::clone(&barrier);
1139 waiters.push(tokio::spawn(async move {
1140 barrier.wait().await;
1141 awaiter.await_terminal("proc").await
1142 }));
1143 }
1144 barrier.wait().await;
1147 let output = success(serde_json::json!({ "raced": true }));
1148 registry
1149 .complete_process(
1150 "proc",
1151 output.clone(),
1152 crate::ProcessCompletionAuthority::external_owner("test"),
1153 )
1154 .await
1155 .expect("complete");
1156
1157 for waiter in waiters {
1158 let resolved = tokio::time::timeout(Duration::from_secs(2), waiter)
1159 .await
1160 .expect("each racing waiter resolves under 2s")
1161 .expect("join waiter")
1162 .expect("await terminal");
1163 assert_eq!(
1164 resolved, output,
1165 "every concurrent waiter resolves with identical terminal output"
1166 );
1167 }
1168 }
1169
1170 #[tokio::test]
1175 async fn driver_reattach_after_terminal_short_circuits_without_engine_call() {
1176 use std::sync::atomic::Ordering;
1177
1178 let raw = Arc::new(TestLocalProcessRegistry::default()) as Arc<dyn ProcessRegistry>;
1179 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1180 let driver = crate::ProcessWorkDriver::new(raw, Arc::new(NoopRunHandle)).with_attach(
1181 Arc::new(CountingAttach {
1182 calls: Arc::clone(&calls),
1183 }),
1184 );
1185 let registry = driver.process_registry();
1186 registry
1187 .register_process(registration("proc"))
1188 .await
1189 .expect("register");
1190 let output = success(serde_json::json!("reattached"));
1192 registry
1193 .complete_process(
1194 "proc",
1195 output.clone(),
1196 crate::ProcessCompletionAuthority::external_owner("test"),
1197 )
1198 .await
1199 .expect("complete");
1200
1201 let start = std::time::Instant::now();
1203 let resolved = driver.await_terminal("proc").await.expect("await terminal");
1204 assert_eq!(resolved, output);
1205 assert_eq!(
1206 calls.load(Ordering::SeqCst),
1207 0,
1208 "a terminal short-circuit must never call the engine attach"
1209 );
1210 assert!(
1211 start.elapsed() < Duration::from_millis(500),
1212 "a short-circuit resolves without any backoff wait"
1213 );
1214 }
1215
1216 #[derive(Clone, Default)]
1219 struct LossySink {
1220 seen: Arc<Mutex<Vec<u64>>>,
1221 dropped: Arc<Mutex<Vec<u64>>>,
1222 }
1223
1224 #[async_trait::async_trait]
1225 impl ProcessEventSink for LossySink {
1226 async fn emit(&self, event: &ProcessEvent) {
1227 if event.sequence.is_multiple_of(2) {
1228 self.dropped.lock().expect("sink lock").push(event.sequence);
1229 } else {
1230 self.seen.lock().expect("sink lock").push(event.sequence);
1231 }
1232 }
1233 }
1234
1235 #[tokio::test]
1240 async fn lossy_sink_still_reconciles_complete_log_from_events_after() {
1241 let raw = Arc::new(TestLocalProcessRegistry::default()) as Arc<dyn ProcessRegistry>;
1242 let sink = LossySink::default();
1243 let (registry, _hub) = watch_process_registry_with_sink(raw, Some(Arc::new(sink.clone())));
1244 registry
1245 .register_process(registration_with_events("proc", &["producer.step"]))
1246 .await
1247 .expect("register");
1248
1249 const EVENTS: u64 = 6;
1250 for _ in 0..EVENTS {
1251 registry
1252 .append_event(
1253 "proc",
1254 ProcessEventAppendRequest::new("producer.step", serde_json::json!({})),
1255 )
1256 .await
1257 .expect("append");
1258 }
1259 registry
1262 .complete_process(
1263 "proc",
1264 success(serde_json::json!("done")),
1265 crate::ProcessCompletionAuthority::external_owner("test"),
1266 )
1267 .await
1268 .expect("complete");
1269
1270 assert!(
1272 !sink.dropped.lock().expect("sink lock").is_empty(),
1273 "the lossy sink must drop at least one emit for the scenario to be meaningful"
1274 );
1275 assert!(
1276 (sink.seen.lock().expect("sink lock").len() as u64) < EVENTS,
1277 "the sink observed fewer events than were appended"
1278 );
1279 let reconciled = registry
1281 .events_after("proc", 0)
1282 .await
1283 .expect("events")
1284 .into_iter()
1285 .filter(|event| event.event_type == "producer.step")
1286 .map(|event| event.sequence)
1287 .collect::<Vec<_>>();
1288 assert_eq!(
1289 reconciled,
1290 (1..=EVENTS).collect::<Vec<_>>(),
1291 "events_after reconciles the complete non-terminal log despite push loss"
1292 );
1293 }
1294}