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