1use std::path::Path;
24use std::sync::{Arc, Mutex, PoisonError};
25
26use anyhow::{anyhow, bail, Context, Result};
27use async_trait::async_trait;
28use git2::Repository;
29use serde_json::{json, Value};
30use tokio::task::JoinHandle;
31use tokio_util::sync::CancellationToken;
32
33use crate::daemon::service::{
34 DaemonService, MenuAction, MenuItem, MenuSnapshot, ServiceStatus, ServiceStream,
35};
36use crate::daemon::services::worktrees::focus_window;
37use crate::sessions::{ObserveRequest, SessionEntry, SessionState, SessionsRegistry, WindowReport};
38
39pub const SERVICE_NAME: &str = "sessions";
41
42const SUBMENU_TITLE: &str = "Agent Sessions";
45
46struct WatcherTask {
49 token: CancellationToken,
51 handles: Vec<JoinHandle<()>>,
53}
54
55pub struct SessionsService {
57 registry: Arc<SessionsRegistry>,
60 watcher: Mutex<Option<WatcherTask>>,
63}
64
65impl SessionsService {
66 #[must_use]
70 pub fn new() -> Self {
71 Self {
72 registry: Arc::new(SessionsRegistry::new()),
73 watcher: Mutex::new(None),
74 }
75 }
76
77 pub fn start_watcher(&self) {
87 if tokio::runtime::Handle::try_current().is_err() {
88 tracing::debug!("no tokio runtime; sessions transcript watcher not started");
89 return;
90 }
91 let mut guard = self.watcher.lock().unwrap_or_else(PoisonError::into_inner);
92 if guard.is_some() {
93 return;
94 }
95 let token = CancellationToken::new();
96 let handles = vec![
97 crate::sessions::watcher::spawn(self.registry.clone(), token.clone()),
98 crate::sessions::codex_watcher::spawn(self.registry.clone(), token.clone()),
99 crate::sessions::pid_watcher::spawn(self.registry.clone(), token.clone()),
100 ];
101 *guard = Some(WatcherTask { token, handles });
102 }
103
104 #[cfg(test)]
106 pub(crate) fn registry(&self) -> &Arc<SessionsRegistry> {
107 &self.registry
108 }
109}
110
111impl Default for SessionsService {
112 fn default() -> Self {
113 Self::new()
114 }
115}
116
117#[async_trait]
118impl DaemonService for SessionsService {
119 fn name(&self) -> &'static str {
120 SERVICE_NAME
121 }
122
123 async fn handle(&self, op: &str, payload: Value) -> Result<Value> {
124 match op {
125 "observe" => {
126 let mut req: ObserveRequest =
127 serde_json::from_value(payload).context("invalid `observe` payload")?;
128 if req.session_id.trim().is_empty() {
129 bail!("`observe` requires a non-empty `session_id`");
130 }
131 if req.repo.is_none() {
136 if let Some(cwd) = req.cwd.clone() {
137 req.repo = tokio::task::spawn_blocking(move || repo_name_for(&cwd))
138 .await
139 .unwrap_or_default();
140 }
141 }
142 self.registry.observe(req);
143 Ok(json!({ "ok": true }))
144 }
145 "end" => {
146 let session_id = require_str(&payload, "session_id", "end")?;
147 let reason = payload.get("reason").and_then(Value::as_str);
148 let pid = payload
151 .get("pid")
152 .and_then(Value::as_u64)
153 .and_then(|pid| u32::try_from(pid).ok());
154 Ok(json!({ "ended": self.registry.end(session_id, reason, pid) }))
155 }
156 "window" => {
157 let req: WindowReport =
158 serde_json::from_value(payload).context("invalid `window` payload")?;
159 if req.key.trim().is_empty() {
160 bail!("`window` requires a non-empty `key`");
161 }
162 self.registry.report_window(req);
163 Ok(json!({ "ok": true }))
164 }
165 "window-unregister" => {
166 let key = require_str(&payload, "key", "window-unregister")?;
167 Ok(json!({ "removed": self.registry.unregister_window(key) }))
168 }
169 "list" => Ok(sessions_payload(&self.registry)),
170 other => bail!("unknown sessions op: {other}"),
171 }
172 }
173
174 fn subscribe(&self, op: &str, _payload: &Value) -> Option<Box<dyn ServiceStream>> {
175 if op != "subscribe" {
179 return None;
180 }
181 Some(Box::new(SessionsStream {
182 registry: self.registry.clone(),
183 changes: self.registry.subscribe_changes(),
186 }))
187 }
188
189 fn menu(&self) -> MenuSnapshot {
190 MenuSnapshot {
194 title: SUBMENU_TITLE.to_string(),
195 items: menu_items_for(&self.registry.list()),
196 }
197 }
198
199 async fn menu_action(&self, action_id: &str) -> Result<()> {
200 if let Some(session_id) = action_id.strip_prefix("focus:") {
201 let folder = self.registry.focus_folder(session_id).ok_or_else(|| {
202 anyhow!("session {session_id} is not open in a known VS Code window")
203 })?;
204 focus_window(&folder)?;
205 return Ok(());
206 }
207 bail!("unknown sessions menu action: {action_id}")
208 }
209
210 async fn status(&self) -> ServiceStatus {
211 let sessions = self.registry.list();
212 let summary = status_summary(&sessions);
213 ServiceStatus {
214 name: SERVICE_NAME.to_string(),
215 healthy: true,
216 summary,
217 detail: json!({ "sessions": sessions }),
218 }
219 }
220
221 async fn shutdown(&self) {
222 let task = self
226 .watcher
227 .lock()
228 .unwrap_or_else(PoisonError::into_inner)
229 .take();
230 if let Some(task) = task {
231 task.token.cancel();
232 for handle in task.handles {
233 let _ = handle.await;
234 }
235 }
236 }
237}
238
239fn sessions_payload(registry: &SessionsRegistry) -> Value {
243 json!({ "sessions": registry.list() })
244}
245
246struct SessionsStream {
254 registry: Arc<SessionsRegistry>,
256 changes: tokio::sync::watch::Receiver<u64>,
261}
262
263#[async_trait]
264impl ServiceStream for SessionsStream {
265 async fn changed(&mut self) {
266 if self.changes.changed().await.is_err() {
272 std::future::pending::<()>().await;
273 }
274 }
275
276 async fn snapshot(&self) -> Value {
277 sessions_payload(&self.registry)
278 }
279}
280
281fn require_str<'a>(payload: &'a Value, field: &str, op: &str) -> Result<&'a str> {
284 payload
285 .get(field)
286 .and_then(Value::as_str)
287 .ok_or_else(|| anyhow!("`{op}` requires `{field}`"))
288}
289
290fn repo_name_for(cwd: &Path) -> Option<String> {
295 let repo = Repository::discover(cwd).ok()?;
296 main_repo_name(repo.commondir())
297}
298
299fn main_repo_name(commondir: &Path) -> Option<String> {
304 let file_name = commondir.file_name()?.to_string_lossy().into_owned();
305 if file_name == ".git" {
306 commondir
307 .parent()
308 .and_then(Path::file_name)
309 .map(|n| n.to_string_lossy().into_owned())
310 } else {
311 Some(
312 file_name
313 .strip_suffix(".git")
314 .unwrap_or(&file_name)
315 .to_string(),
316 )
317 }
318}
319
320fn status_summary(sessions: &[SessionEntry]) -> String {
322 if sessions.is_empty() {
323 return "0 session(s)".to_string();
324 }
325 let mut working = 0;
326 let mut waiting = 0;
327 let mut idle = 0;
328 for s in sessions {
329 match s.state {
330 SessionState::Working => working += 1,
331 SessionState::WaitingForInput | SessionState::WaitingForPermission => waiting += 1,
332 SessionState::Starting | SessionState::Idle | SessionState::Ended => idle += 1,
334 }
335 }
336 format!(
337 "{} session(s): {working} working, {waiting} waiting, {idle} idle",
338 sessions.len()
339 )
340}
341
342fn state_glyph(state: SessionState) -> &'static str {
344 match state {
345 SessionState::Starting => "…",
346 SessionState::Working => "⚙",
347 SessionState::Idle => "◦",
348 SessionState::WaitingForInput => "?",
349 SessionState::WaitingForPermission => "!",
350 SessionState::Ended => "×",
351 }
352}
353
354fn display_name(entry: &SessionEntry) -> String {
357 if let Some(repo) = &entry.repo {
358 return repo.clone();
359 }
360 if let Some(cwd) = &entry.cwd {
361 if let Some(name) = cwd.file_name() {
362 return name.to_string_lossy().into_owned();
363 }
364 }
365 entry.session_id.chars().take(8).collect()
367}
368
369fn menu_items_for(sessions: &[SessionEntry]) -> Vec<MenuItem> {
375 use crate::sessions::Source;
376 if sessions.is_empty() {
377 return vec![MenuItem::Label("No active sessions".to_string())];
378 }
379 sessions
380 .iter()
381 .map(|entry| {
382 let label = format!(
383 "{} · {} {} {}",
384 entry.agent.display_name(),
385 display_name(entry),
386 state_glyph(entry.state),
387 state_label(entry.state),
388 );
389 match &entry.source {
390 Source::VsCode { .. } => MenuItem::Action(MenuAction {
391 id: format!("focus:{}", entry.session_id),
392 label,
393 enabled: true,
394 }),
395 Source::Terminal => MenuItem::Label(label),
396 }
397 })
398 .collect()
399}
400
401fn state_label(state: SessionState) -> &'static str {
404 match state {
405 SessionState::Starting => "starting",
406 SessionState::Working => "working",
407 SessionState::Idle => "idle",
408 SessionState::WaitingForInput => "waiting",
409 SessionState::WaitingForPermission => "permission",
410 SessionState::Ended => "ended",
411 }
412}
413
414#[cfg(test)]
415#[allow(clippy::unwrap_used, clippy::expect_used)]
416mod tests {
417 use super::*;
418 use crate::sessions::{NotificationKind, SessionEvent, Source};
419 use std::collections::VecDeque;
420 use std::path::PathBuf;
421
422 fn service() -> SessionsService {
423 SessionsService::new()
424 }
425
426 #[tokio::test]
427 async fn observe_then_list_round_trips() {
428 let svc = service();
429 let ok = svc
430 .handle(
431 "observe",
432 json!({ "session_id": "s1", "event": "session_start", "cwd": "/tmp/x" }),
433 )
434 .await
435 .unwrap();
436 assert_eq!(ok, json!({ "ok": true }));
437
438 let listed = svc.handle("list", Value::Null).await.unwrap();
439 let sessions = listed["sessions"].as_array().unwrap();
440 assert_eq!(sessions.len(), 1);
441 assert_eq!(sessions[0]["session_id"], "s1");
442 assert_eq!(sessions[0]["state"], "starting");
443 }
444
445 #[tokio::test]
446 async fn observe_rejects_blank_session_id() {
447 let svc = service();
448 let err = svc
449 .handle("observe", json!({ "session_id": " ", "event": "stop" }))
450 .await
451 .unwrap_err();
452 assert!(err.to_string().contains("session_id"), "{err}");
453 }
454
455 #[tokio::test]
456 async fn end_marks_ended() {
457 let svc = service();
458 svc.handle(
459 "observe",
460 json!({ "session_id": "s1", "event": "pre_tool_use" }),
461 )
462 .await
463 .unwrap();
464 let reply = svc
465 .handle("end", json!({ "session_id": "s1", "reason": "clear" }))
466 .await
467 .unwrap();
468 assert_eq!(reply, json!({ "ended": true }));
469 let reply = svc
471 .handle("end", json!({ "session_id": "ghost" }))
472 .await
473 .unwrap();
474 assert_eq!(reply, json!({ "ended": false }));
475 }
476
477 #[tokio::test]
478 async fn window_report_tags_source_and_unregister_removes() {
479 let svc = service();
480 svc.handle(
481 "observe",
482 json!({ "session_id": "s1", "event": "pre_tool_use", "cwd": "/home/me/proj/sub" }),
483 )
484 .await
485 .unwrap();
486 svc.handle(
487 "window",
488 json!({ "key": "w1", "folders": ["/home/me/proj"], "tabs": 1, "terminals": 0 }),
489 )
490 .await
491 .unwrap();
492 let listed = svc.handle("list", Value::Null).await.unwrap();
493 assert_eq!(listed["sessions"][0]["source"]["kind"], "vs_code");
494 assert_eq!(listed["sessions"][0]["source"]["window_key"], "w1");
495
496 let removed = svc
497 .handle("window-unregister", json!({ "key": "w1" }))
498 .await
499 .unwrap();
500 assert_eq!(removed, json!({ "removed": true }));
501 let listed = svc.handle("list", Value::Null).await.unwrap();
502 assert_eq!(listed["sessions"][0]["source"]["kind"], "terminal");
503 }
504
505 #[tokio::test]
506 async fn unknown_op_errors() {
507 let svc = service();
508 let err = svc.handle("frobnicate", Value::Null).await.unwrap_err();
509 assert!(err.to_string().contains("unknown sessions op"), "{err}");
510 }
511
512 #[tokio::test]
513 async fn status_summarizes_states() {
514 let svc = service();
515 svc.registry().observe(ObserveRequest {
516 pid: None,
517 agent: crate::sessions::Agent::Claude,
518 session_id: "w".to_string(),
519 cwd: None,
520 transcript_path: None,
521 event: SessionEvent::PreToolUse,
522 repo: None,
523 model: None,
524 });
525 svc.registry().observe(ObserveRequest {
526 pid: None,
527 agent: crate::sessions::Agent::Claude,
528 session_id: "p".to_string(),
529 cwd: None,
530 transcript_path: None,
531 event: SessionEvent::Notification(NotificationKind::PermissionPrompt),
532 repo: None,
533 model: None,
534 });
535 let status = svc.status().await;
536 assert!(status.healthy);
537 assert!(
538 status.summary.contains("2 session(s)"),
539 "{}",
540 status.summary
541 );
542 assert!(status.summary.contains("1 working"), "{}", status.summary);
543 assert!(status.summary.contains("1 waiting"), "{}", status.summary);
544 }
545
546 #[test]
547 fn menu_items_placeholder_when_empty() {
548 let items = menu_items_for(&[]);
549 assert_eq!(items.len(), 1);
550 assert!(matches!(&items[0], MenuItem::Label(l) if l.contains("No active")));
551 }
552
553 #[test]
554 fn menu_item_is_clickable_only_for_vscode_sessions() {
555 let now = chrono::Utc::now();
556 let base = |source: Source| SessionEntry {
557 pid: None,
558 pid_start: None,
559 prompted: false,
560 replaced_pids: VecDeque::new(),
561 agent: crate::sessions::Agent::Claude,
562 session_id: "sid-12345678".to_string(),
563 cwd: Some(PathBuf::from("/p")),
564 transcript_path: None,
565 repo: Some("proj".to_string()),
566 model: None,
567 state: SessionState::Working,
568 source,
569 last_event: SessionEvent::PreToolUse,
570 started_at: now,
571 last_seen: now,
572 };
573 let terminal = menu_items_for(&[base(Source::Terminal)]);
575 assert!(
576 matches!(&terminal[0], MenuItem::Label(l) if l.contains("proj") && l.contains("working"))
577 );
578 let vscode = menu_items_for(&[base(Source::VsCode {
580 window_key: "w1".to_string(),
581 })]);
582 match &vscode[0] {
583 MenuItem::Action(a) => assert_eq!(a.id, "focus:sid-12345678"),
584 other => panic!("expected an action, got {other:?}"),
585 }
586 }
587
588 #[test]
589 fn repo_name_for_non_repo_is_none() {
590 assert_eq!(repo_name_for(Path::new("/nonexistent/xyz")), None);
592 }
593
594 #[test]
595 fn main_repo_name_handles_layouts() {
596 assert_eq!(
597 main_repo_name(Path::new("/home/me/proj/.git")).as_deref(),
598 Some("proj")
599 );
600 assert_eq!(
601 main_repo_name(Path::new("/home/me/bare.git")).as_deref(),
602 Some("bare")
603 );
604 }
605
606 fn entry(id: &str, state: SessionState, repo: Option<&str>, cwd: Option<&str>) -> SessionEntry {
608 let now = chrono::Utc::now();
609 SessionEntry {
610 pid: None,
611 pid_start: None,
612 prompted: false,
613 replaced_pids: VecDeque::new(),
614 agent: crate::sessions::Agent::Claude,
615 session_id: id.to_string(),
616 cwd: cwd.map(PathBuf::from),
617 transcript_path: None,
618 repo: repo.map(str::to_string),
619 model: None,
620 state,
621 source: Source::Terminal,
622 last_event: SessionEvent::PreToolUse,
623 started_at: now,
624 last_seen: now,
625 }
626 }
627
628 #[test]
629 fn default_constructs_an_empty_service() {
630 let svc = SessionsService::default();
631 assert!(svc.registry().list().is_empty());
632 }
633
634 #[test]
635 fn start_watcher_is_a_noop_outside_a_runtime() {
636 let svc = SessionsService::new();
638 svc.start_watcher();
639 assert!(svc
640 .watcher
641 .lock()
642 .unwrap_or_else(PoisonError::into_inner)
643 .is_none());
644 }
645
646 #[tokio::test]
647 async fn start_watcher_is_idempotent_and_shutdown_stops_it() {
648 let svc = SessionsService::new();
649 svc.start_watcher();
650 svc.start_watcher();
652 assert!(svc
653 .watcher
654 .lock()
655 .unwrap_or_else(PoisonError::into_inner)
656 .is_some());
657 svc.shutdown().await;
659 assert!(svc
660 .watcher
661 .lock()
662 .unwrap_or_else(PoisonError::into_inner)
663 .is_none());
664 }
665
666 #[test]
667 fn menu_renders_every_state_and_name_fallback() {
668 let sessions = vec![
669 entry("s1", SessionState::Starting, Some("repo-a"), Some("/a")),
670 entry("s2", SessionState::Working, None, Some("/home/me/proj")),
671 entry("s3", SessionState::Idle, None, None),
672 entry("s4", SessionState::WaitingForInput, Some("r"), Some("/b")),
673 entry(
674 "s5",
675 SessionState::WaitingForPermission,
676 Some("r"),
677 Some("/c"),
678 ),
679 entry("s6", SessionState::Ended, Some("r"), Some("/d")),
680 ];
681 let items = menu_items_for(&sessions);
682 assert_eq!(items.len(), 6);
683 let labels: Vec<&str> = items
686 .iter()
687 .map(|i| match i {
688 MenuItem::Label(l) => l.as_str(),
689 _ => panic!("terminal sessions render as labels"),
690 })
691 .collect();
692 assert!(labels[0].contains("repo-a") && labels[0].contains("starting"));
693 assert!(labels[1].contains("proj") && labels[1].contains("working")); assert!(labels[2].contains("s3") && labels[2].contains("idle")); assert!(labels[3].contains("waiting"));
696 assert!(labels[4].contains("permission"));
697 assert!(labels[5].contains("ended"));
698 }
699
700 #[test]
701 fn menu_rows_are_prefixed_with_their_agent() {
702 let mut pi = entry("p1", SessionState::Idle, Some("repo-p"), None);
703 pi.agent = crate::sessions::Agent::Pi;
704 let mut codex = entry("c1", SessionState::Working, Some("repo-c"), None);
705 codex.agent = crate::sessions::Agent::Codex;
706 let claude = entry("s1", SessionState::Idle, Some("repo-a"), None);
707 let items = menu_items_for(&[claude, pi, codex]);
708 let labels: Vec<&str> = items
709 .iter()
710 .map(|i| match i {
711 MenuItem::Label(l) => l.as_str(),
712 _ => panic!("terminal sessions render as labels"),
713 })
714 .collect();
715 assert_eq!(labels[0], "Claude · repo-a ◦ idle");
716 assert_eq!(labels[1], "pi · repo-p ◦ idle");
717 assert_eq!(labels[2], "Codex · repo-c ⚙ working");
718 assert_eq!(SUBMENU_TITLE, "Agent Sessions");
719 }
720
721 #[test]
722 fn menu_serves_the_snapshot_title() {
723 let svc = SessionsService::new();
724 svc.registry()
725 .observe(observe_req("s1", SessionEvent::Stop, None));
726 let snapshot = svc.menu();
727 assert_eq!(snapshot.title, SUBMENU_TITLE);
728 assert_eq!(snapshot.items.len(), 1);
729 }
730
731 fn observe_req(id: &str, event: SessionEvent, cwd: Option<&str>) -> ObserveRequest {
733 ObserveRequest {
734 pid: None,
735 agent: crate::sessions::Agent::Claude,
736 session_id: id.to_string(),
737 cwd: cwd.map(PathBuf::from),
738 transcript_path: None,
739 event,
740 repo: None,
741 model: None,
742 }
743 }
744
745 #[tokio::test]
746 async fn menu_action_errors_on_unknown_and_missing_window() {
747 let svc = SessionsService::new();
748 let err = svc.menu_action("frobnicate").await.unwrap_err();
750 assert!(
751 err.to_string().contains("unknown sessions menu action"),
752 "{err}"
753 );
754 let err = svc.menu_action("focus:nope").await.unwrap_err();
756 assert!(
757 err.to_string()
758 .contains("not open in a known VS Code window"),
759 "{err}"
760 );
761 }
762
763 #[tokio::test]
764 async fn end_op_ignores_the_replaced_process_by_pid() {
765 let svc = SessionsService::new();
766 for pid in [100, 200] {
767 svc.handle(
768 "observe",
769 json!({ "session_id": "s1", "event": "session_start", "pid": pid }),
770 )
771 .await
772 .unwrap();
773 }
774 svc.handle("end", json!({ "session_id": "s1", "pid": 100 }))
775 .await
776 .unwrap();
777 assert_eq!(svc.registry().list()[0].state, SessionState::Starting);
778 svc.handle("end", json!({ "session_id": "s1", "pid": 200 }))
779 .await
780 .unwrap();
781 assert_eq!(svc.registry().list()[0].state, SessionState::Ended);
782 }
783
784 #[test]
785 fn repo_name_for_resolves_a_real_repo() {
786 let tmp = tempfile::tempdir().unwrap();
787 let repo_dir = tmp.path().join("myrepo");
788 std::fs::create_dir(&repo_dir).unwrap();
789 git2::Repository::init(&repo_dir).unwrap();
790 assert_eq!(repo_name_for(&repo_dir).as_deref(), Some("myrepo"));
792 }
793
794 #[tokio::test]
795 async fn status_summary_counts_idle_and_ended() {
796 let svc = SessionsService::new();
797 svc.registry()
798 .observe(observe_req("i", SessionEvent::Stop, None)); svc.registry().end("i2", None, None); svc.registry()
801 .observe(observe_req("i2", SessionEvent::PreToolUse, None));
802 svc.registry().end("i2", Some("done"), None); svc.registry()
804 .observe(observe_req("s", SessionEvent::SessionStart, None)); let status = svc.status().await;
806 assert!(status.summary.contains("3 idle"), "{}", status.summary);
809 assert!(status.summary.contains("0 working"), "{}", status.summary);
810 }
811
812 #[tokio::test]
815 async fn subscribe_streams_only_for_the_subscribe_op() {
816 let svc = service();
817 assert!(svc.subscribe("subscribe", &Value::Null).is_some());
820 assert!(svc.subscribe("list", &Value::Null).is_none());
821 assert!(svc.subscribe("observe", &Value::Null).is_none());
822 assert!(svc.subscribe("window", &Value::Null).is_none());
823 assert!(svc.subscribe("bogus", &Value::Null).is_none());
824 }
825
826 #[tokio::test]
827 async fn subscribe_snapshot_matches_the_list_op() {
828 let svc = service();
829 let stream = svc
830 .subscribe("subscribe", &Value::Null)
831 .expect("subscribe stream");
832 assert_eq!(stream.snapshot().await, json!({ "sessions": [] }));
834
835 svc.handle(
836 "observe",
837 json!({ "session_id": "s1", "event": "pre_tool_use", "cwd": "/tmp/x" }),
838 )
839 .await
840 .unwrap();
841 let snap = stream.snapshot().await;
844 let listed = svc.handle("list", Value::Null).await.unwrap();
845 assert_eq!(snap, listed);
846 assert_eq!(snap["sessions"][0]["state"], json!("working"));
847 }
848
849 #[tokio::test]
850 async fn subscribe_changed_wakes_on_a_visible_change() {
851 let svc = service();
852 let mut stream = svc
853 .subscribe("subscribe", &Value::Null)
854 .expect("subscribe stream");
855 tokio::select! {
857 () = stream.changed() => panic!("changed resolved with no registry change"),
858 () = tokio::time::sleep(std::time::Duration::from_millis(50)) => {}
859 }
860 svc.handle(
862 "observe",
863 json!({ "session_id": "s1", "event": "session_start" }),
864 )
865 .await
866 .unwrap();
867 tokio::time::timeout(std::time::Duration::from_secs(2), stream.changed())
868 .await
869 .expect("changed should resolve after a visible change");
870 }
871
872 #[tokio::test]
873 async fn changed_parks_when_every_sender_is_gone() {
874 let (tx, rx) = tokio::sync::watch::channel(0u64);
879 let mut stream = SessionsStream {
880 registry: Arc::new(SessionsRegistry::new()),
881 changes: rx,
882 };
883 drop(tx);
884 tokio::select! {
885 () = stream.changed() => panic!("changed resolved after the sender was dropped"),
886 () = tokio::time::sleep(std::time::Duration::from_millis(50)) => {}
887 }
888 }
889}