@team-agent/installer 0.5.108 → 0.5.109

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Cargo.lock CHANGED
@@ -581,7 +581,7 @@ dependencies = [
581
581
 
582
582
  [[package]]
583
583
  name = "team-agent"
584
- version = "0.5.108"
584
+ version = "0.5.109"
585
585
  dependencies = [
586
586
  "anyhow",
587
587
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent", "crates/win-conpty-phase0", "crates/conpty-trans
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.5.108"
12
+ version = "0.5.109"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -5,7 +5,7 @@
5
5
  //! status brief contract.
6
6
 
7
7
  use serde_json::{json, Map, Value};
8
- use std::collections::BTreeMap;
8
+ use std::collections::{BTreeMap, BTreeSet};
9
9
  use std::path::Path;
10
10
  use std::process::Command;
11
11
 
@@ -548,11 +548,37 @@ fn sample_native_endpoint(
548
548
  registered: &[RegisteredNode],
549
549
  ) -> Option<ProbeResult> {
550
550
  let output = run_native_tmux_list_panes(endpoint)?;
551
- let nodes = output
552
- .lines()
553
- .filter_map(|line| parse_native_pane(line, endpoint, registered))
554
- .collect::<Vec<_>>();
555
- Some(ProbeResult { nodes })
551
+ Some(sample_native_panes(&output, endpoint, registered, process_snapshots))
552
+ }
553
+
554
+ fn sample_native_panes(
555
+ output: &str,
556
+ endpoint: &str,
557
+ registered: &[RegisteredNode],
558
+ snapshots: impl FnOnce(&BTreeSet<u32>) -> BTreeMap<u32, ProcessSnapshot>,
559
+ ) -> ProbeResult {
560
+ let panes = output.lines().filter_map(native_pane_fields).collect::<Vec<_>>();
561
+ let pids = panes.iter().filter_map(|fields| native_pane_pid(fields[3])).collect::<BTreeSet<_>>();
562
+ let processes = if pids.is_empty() { BTreeMap::new() } else { snapshots(&pids) };
563
+ let nodes = panes.into_iter()
564
+ .filter_map(|fields| parse_native_pane(fields, endpoint, registered, &processes))
565
+ .collect();
566
+ ProbeResult { nodes }
567
+ }
568
+
569
+ fn native_pane_fields(line: &str) -> Option<[&str; 5]> {
570
+ let mut fields = line.split('\t');
571
+ let session = fields.next()?;
572
+ let window = fields.next()?;
573
+ let pane = fields.next()?;
574
+ let pid = fields.next().unwrap_or_default();
575
+ let command = fields.next().unwrap_or_default();
576
+ if session.is_empty() || window.is_empty() || pane.is_empty() { return None; }
577
+ Some([session, window, pane, pid, command])
578
+ }
579
+
580
+ fn native_pane_pid(pid: &str) -> Option<u32> {
581
+ pid.parse::<u32>().ok().filter(|pid| *pid > 0 && *pid <= i32::MAX as u32)
556
582
  }
557
583
 
558
584
  fn run_native_tmux_list_panes(endpoint: &str) -> Option<String> {
@@ -568,27 +594,23 @@ fn run_native_tmux_list_panes(endpoint: &str) -> Option<String> {
568
594
  }
569
595
 
570
596
  fn parse_native_pane(
571
- line: &str,
597
+ fields: [&str; 5],
572
598
  endpoint: &str,
573
599
  registered: &[RegisteredNode],
600
+ processes: &BTreeMap<u32, ProcessSnapshot>,
574
601
  ) -> Option<ProbeNode> {
575
- let mut fields = line.split('\t');
576
- let session = fields.next()?.to_string();
577
- let window = fields.next()?.to_string();
578
- let pane = fields.next()?.to_string();
579
- let pid = fields.next().filter(|value| !value.is_empty());
580
- let command = fields.next().unwrap_or_default();
581
- if session.is_empty() || window.is_empty() || pane.is_empty() {
582
- return None;
583
- }
602
+ let [session, window, pane, pid, command] = fields;
603
+ let session = session.to_string();
604
+ let window = window.to_string();
605
+ let pane = pane.to_string();
584
606
  let matched = registered.iter().find(|node| {
585
607
  node.endpoint.as_deref() == Some(endpoint)
586
608
  && node.session.as_deref() == Some(session.as_str())
587
609
  && node.window.as_deref().is_none_or(|value| value == window)
588
610
  && node.pane.as_deref() == Some(pane.as_str())
589
611
  });
590
- if let Some(pid) = pid {
591
- let snapshot = process_snapshot(pid)?;
612
+ if !pid.is_empty() {
613
+ let snapshot = processes.get(&native_pane_pid(pid)?)?;
592
614
  if snapshot.zombie {
593
615
  return None;
594
616
  }
@@ -620,22 +642,35 @@ struct ProcessSnapshot {
620
642
  zombie: bool,
621
643
  }
622
644
 
623
- fn process_snapshot(pid: &str) -> Option<ProcessSnapshot> {
624
- let output = Command::new("ps")
625
- .args(["-o", "pid=,ppid=,stat=,comm=", "-p", pid])
626
- .output()
627
- .ok()?;
628
- if !output.status.success() || String::from_utf8_lossy(&output.stdout).trim().is_empty() {
629
- return None;
645
+ fn process_snapshot_command(pids: &BTreeSet<u32>) -> Command {
646
+ let pids = pids.iter().map(u32::to_string).collect::<Vec<_>>().join(",");
647
+ let mut command = Command::new("ps");
648
+ command.args(["-o", "pid=,ppid=,stat=,comm=", "-p", &pids]);
649
+ command
650
+ }
651
+
652
+ fn process_snapshots(pids: &BTreeSet<u32>) -> BTreeMap<u32, ProcessSnapshot> {
653
+ if pids.is_empty() { return BTreeMap::new(); }
654
+ let Ok(output) = process_snapshot_command(pids).output() else { return BTreeMap::new(); };
655
+ if !output.status.success() { return BTreeMap::new(); }
656
+ parse_process_snapshots(&output.stdout, pids)
657
+ }
658
+
659
+ fn parse_process_snapshots(bytes: &[u8], requested: &BTreeSet<u32>) -> BTreeMap<u32, ProcessSnapshot> {
660
+ let mut snapshots = BTreeMap::<u32, ProcessSnapshot>::new();
661
+ let output = String::from_utf8_lossy(bytes);
662
+ for line in output.lines() {
663
+ let mut fields = line.split_whitespace();
664
+ let Some(pid) = fields.next().and_then(native_pane_pid) else { continue; };
665
+ if !requested.contains(&pid) { continue; }
666
+ let Some(_ppid) = fields.next() else { continue; };
667
+ let Some(stat) = fields.next() else { continue; };
668
+ let zombie = stat.contains('Z');
669
+ snapshots.entry(pid)
670
+ .and_modify(|snapshot| snapshot.zombie |= zombie)
671
+ .or_insert(ProcessSnapshot { zombie });
630
672
  }
631
- let output_text = String::from_utf8_lossy(&output.stdout);
632
- let stat = output_text
633
- .split_whitespace()
634
- .nth(2)
635
- .unwrap_or_default();
636
- Some(ProcessSnapshot {
637
- zombie: stat.contains('Z'),
638
- })
673
+ snapshots
639
674
  }
640
675
 
641
676
  #[cfg(test)]
@@ -1335,6 +1370,162 @@ impl BriefNode {
1335
1370
  }
1336
1371
  }
1337
1372
 
1373
+ #[cfg(test)]
1374
+ mod native_ps_batch_regressions {
1375
+ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
1376
+ use super::*;
1377
+
1378
+ fn registered(endpoint: &str, pane: &str) -> RegisteredNode {
1379
+ RegisteredNode {
1380
+ name: format!("{endpoint}-{pane}"),
1381
+ provider: "pi".into(),
1382
+ model: Some("fixture-model".into()),
1383
+ effort: Some("high".into()),
1384
+ endpoint: Some(endpoint.into()),
1385
+ session: Some("hp".into()),
1386
+ window: Some("w".into()),
1387
+ pane: Some(pane.into()),
1388
+ lifecycle: None,
1389
+ }
1390
+ }
1391
+
1392
+ #[test]
1393
+ fn eight_panes_use_one_endpoint_query() {
1394
+ let registered = (0..8).map(|i| registered("e1", &format!("%{i}"))).collect::<Vec<_>>();
1395
+ let panes = (0..8).map(|i| format!("hp\tw\t%{i}\t{}\tpi\n", 100 + i)).collect::<String>();
1396
+ let rows = (0..8).map(|i| format!("{} 1 S pi\n", 100 + i)).collect::<String>();
1397
+ let mut calls = Vec::new();
1398
+ let result = sample_native_panes(&panes, "e1", &registered, |pids| {
1399
+ calls.push(pids.iter().copied().collect::<Vec<_>>());
1400
+ parse_process_snapshots(rows.as_bytes(), pids)
1401
+ });
1402
+ assert_eq!(calls, vec![(100..108).collect::<Vec<_>>()]);
1403
+ assert_eq!(result.nodes.len(), 8);
1404
+ assert!(result.nodes.iter().all(|node| node.provider == "pi" && node.activity == "working" && node.health == "normal"));
1405
+ }
1406
+
1407
+ #[test]
1408
+ fn pid_deduplication_and_command_preserve_the_privacy_whitelist() {
1409
+ let mut calls = Vec::new();
1410
+ let result = sample_native_panes("hp\tw\t%1\t11\tsh\nhp\tw\t%2\t011\tsh\n", "e1", &[], |pids| {
1411
+ calls.push(pids.iter().copied().collect::<Vec<_>>());
1412
+ parse_process_snapshots(b"11 1 S sh\n", pids)
1413
+ });
1414
+ assert_eq!(calls, vec![vec![11]]);
1415
+ assert_eq!(result.nodes.len(), 2);
1416
+ let pids = BTreeSet::from([22, 11]);
1417
+ let command = process_snapshot_command(&pids);
1418
+ assert_eq!(command.get_program(), "ps");
1419
+ assert_eq!(
1420
+ command.get_args().map(|arg| arg.to_string_lossy().into_owned()).collect::<Vec<_>>(),
1421
+ vec!["-o", "pid=,ppid=,stat=,comm=", "-p", "11,22"]
1422
+ );
1423
+ let command = process_snapshot_command(&BTreeSet::from([11]));
1424
+ assert_eq!(command.get_args().last().unwrap(), "11");
1425
+ }
1426
+
1427
+ #[cfg(unix)]
1428
+ #[test]
1429
+ fn native_ps_partial_selection_preserves_a_live_pid() {
1430
+ let pid = std::process::id();
1431
+ let requested = BTreeSet::from([pid, i32::MAX as u32]);
1432
+ let command = process_snapshot_command(&requested);
1433
+ // Absolute ps avoids unrelated PATH-fixture mutation in parallel unit tests.
1434
+ let output = Command::new("/bin/ps").args(command.get_args()).output().unwrap();
1435
+ assert!(output.status.success(), "partial PID selection must not poison the live row");
1436
+ let snapshots = parse_process_snapshots(&output.stdout, &requested);
1437
+ assert!(!snapshots.get(&pid).unwrap().zombie);
1438
+ assert!(!snapshots.contains_key(&(i32::MAX as u32)));
1439
+ }
1440
+
1441
+ #[test]
1442
+ fn single_pane_keeps_its_native_health_and_activity() {
1443
+ let registered = vec![registered("e1", "%1")];
1444
+ let result = sample_native_panes("hp\tw\t%1\t11\tsh\n", "e1", &registered, |pids| {
1445
+ assert_eq!(pids, &BTreeSet::from([11]));
1446
+ parse_process_snapshots(b"11 1 S sh\n", pids)
1447
+ });
1448
+ assert_eq!(result.nodes.len(), 1);
1449
+ assert_eq!(result.nodes[0].provider, "pi");
1450
+ assert_eq!(result.nodes[0].activity, "idle");
1451
+ assert_eq!(result.nodes[0].health, "normal");
1452
+ }
1453
+
1454
+ #[test]
1455
+ fn missing_rows_and_zombies_omit_only_their_panes() {
1456
+ let panes = "hp\tw\t%1\t11\tsh\nhp\tw\t%2\t22\tpi\nhp\tw\t%3\t33\tpi\nhp\tw\t%4\t\tsh\n";
1457
+ let result = sample_native_panes(panes, "e1", &[], |pids| {
1458
+ parse_process_snapshots(b"22 1 Z+ dead\n11 1 S sh\n999 1 S unrelated\n33 1\n", pids)
1459
+ });
1460
+ assert_eq!(result.nodes.iter().map(|node| node.pane.as_str()).collect::<Vec<_>>(), vec!["%1", "%4"]);
1461
+ assert!(result.nodes.iter().all(|node| node.activity == "idle" && node.health == "normal"));
1462
+ }
1463
+
1464
+ #[test]
1465
+ fn empty_or_invalid_pid_sets_do_not_run_ps() {
1466
+ let panes = "hp\tw\t%1\t\tsh\nhp\tw\t%2\t0\tsh\nhp\tw\t%3\t1,2\tsh\nhp\tw\t%4\t4294967295\tsh\n\tw\t%5\t99\tsh\n";
1467
+ let result = sample_native_panes(panes, "e1", &[], |_| panic!("no valid PID means no ps query"));
1468
+ assert_eq!(result.nodes.len(), 1);
1469
+ assert_eq!(result.nodes[0].pane, "%1");
1470
+ assert_eq!(result.nodes[0].health, "normal");
1471
+ }
1472
+
1473
+ #[test]
1474
+ fn snapshot_failure_and_endpoint_results_are_not_shared() {
1475
+ let registered = vec![registered("e1", "%1"), registered("e2", "%1")];
1476
+ let mut calls = Vec::new();
1477
+ let first = sample_native_panes("hp\tw\t%1\t88\tsh\n", "e1", &registered, |pids| {
1478
+ calls.push(("e1", pids.iter().copied().collect::<Vec<_>>()));
1479
+ BTreeMap::new()
1480
+ });
1481
+ let second = sample_native_panes("hp\tw\t%1\t88\tsh\n", "e2", &registered, |pids| {
1482
+ calls.push(("e2", pids.iter().copied().collect::<Vec<_>>()));
1483
+ parse_process_snapshots(b"88 1 S sh\n", pids)
1484
+ });
1485
+ assert_eq!(calls, vec![("e1", vec![88]), ("e2", vec![88])]);
1486
+ assert!(first.nodes.is_empty());
1487
+ assert_eq!(second.nodes.len(), 1);
1488
+ let failed = Some(first);
1489
+ assert_eq!(project_node_with_attach(&registered[0], Some(&failed)).0.health, "unknown");
1490
+ let live = Some(second);
1491
+ assert_eq!(project_node_with_attach(&registered[1], Some(&live)).0.health, "normal");
1492
+ }
1493
+
1494
+ #[test]
1495
+ fn duplicate_process_rows_cannot_hide_a_zombie() {
1496
+ let requested = BTreeSet::from([11]);
1497
+ for rows in [b"11 1 Z dead\n11 1 S sh\n".as_slice(), b"11 1 S sh\n11 1 Z dead\n".as_slice()] {
1498
+ assert!(parse_process_snapshots(rows, &requested).get(&11).unwrap().zombie);
1499
+ }
1500
+ assert!(parse_process_snapshots(b"not a process row\n", &requested).is_empty());
1501
+ }
1502
+
1503
+ #[test]
1504
+ fn json_and_human_keep_nine_fields_without_ps_details() {
1505
+ let registered = vec![registered("e1", "%1"), registered("e1", "%2")];
1506
+ let probe = Some(sample_native_panes(
1507
+ "hp\tw\t%1\t11\tsh\nhp\tw\t%2\t22\tpi\n", "e1", &registered,
1508
+ |pids| parse_process_snapshots(b"22 1 R PRIVATE_COMMAND_DETAIL\n11 1 S PRIVATE_COMMAND_DETAIL\n", pids),
1509
+ ));
1510
+ let projected = registered.iter().map(|node| project_node_with_attach(node, Some(&probe))).collect::<Vec<_>>();
1511
+ let keys = BTreeSet::from(["name", "provider", "model", "effort", "runtime_status", "activity", "health", "session_name", "tmux_command"]);
1512
+ for (node, _) in &projected {
1513
+ let json = node.clone().into_json();
1514
+ assert_eq!(json.as_object().unwrap().keys().map(String::as_str).collect::<BTreeSet<_>>(), keys);
1515
+ assert_eq!(json["model"], "fixture-model");
1516
+ assert_eq!(json["effort"], "high");
1517
+ assert_eq!(json["runtime_status"], "running");
1518
+ assert_eq!(json["health"], "normal");
1519
+ assert!(!json.to_string().contains("PRIVATE_COMMAND_DETAIL"));
1520
+ }
1521
+ let human = format_multi_node_brief(projected);
1522
+ let mut lines = human.lines().skip_while(|line| !line.starts_with("NAME\t"));
1523
+ assert_eq!(lines.next().unwrap().split('\t').count(), 9);
1524
+ assert!(lines.all(|line| line.split('\t').count() == 9));
1525
+ assert!(!human.contains("PRIVATE_COMMAND_DETAIL"));
1526
+ }
1527
+ }
1528
+
1338
1529
  #[cfg(test)]
1339
1530
  mod tests {
1340
1531
  #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
@@ -589,15 +589,18 @@ pub(crate) fn run_command(
589
589
  Some((writer, rx))
590
590
  } else { None };
591
591
  let mut output = None;
592
+ let mut pending_output = None;
593
+ let mut output_ready_at: Option<Instant> = None;
592
594
  let mut too_large_at = None;
593
595
  let status = loop {
594
- match reader_rx.try_recv() {
596
+ match pending_output.take().map_or_else(|| reader_rx.try_recv(), Ok) {
595
597
  Ok(Ok(bytes)) => {
596
598
  if bytes.len() as u64 > max_bytes {
597
599
  too_large_at.get_or_insert_with(Instant::now);
598
600
  } else {
599
601
  output = Some(bytes);
600
602
  }
603
+ output_ready_at = Some(Instant::now());
601
604
  }
602
605
  Ok(Err(_)) => { terminate(&mut child, pid); return Err(CatalogError::OutputReadFailed); }
603
606
  Err(mpsc::TryRecvError::Disconnected) if output.is_none() && too_large_at.is_none() => { terminate(&mut child, pid); return Err(CatalogError::OutputReadFailed); }
@@ -614,7 +617,24 @@ pub(crate) fn run_command(
614
617
  terminate(&mut child, pid);
615
618
  return Err(CatalogError::OutputTooLarge);
616
619
  }
617
- Ok(None) if started.elapsed() < timeout => std::thread::sleep(Duration::from_millis(10)),
620
+ Ok(None) if started.elapsed() < timeout => {
621
+ let mut wait = timeout.saturating_sub(started.elapsed()).min(Duration::from_millis(10));
622
+ if let Some(over_at) = too_large_at {
623
+ wait = wait.min(Duration::from_millis(100).saturating_sub(over_at.elapsed()));
624
+ }
625
+ if output.is_none() && too_large_at.is_none() {
626
+ // Reader completion wakes us instead of trailing a blind 10ms sleep.
627
+ if let Ok(result) = reader_rx.recv_timeout(wait) {
628
+ pending_output = Some(result);
629
+ }
630
+ } else {
631
+ // stdout can close before exit; never busy-poll a disconnected channel.
632
+ let interval = output_ready_at.map_or(Duration::from_millis(10), |at| {
633
+ catalog_exit_poll_interval(at.elapsed())
634
+ });
635
+ std::thread::sleep(wait.min(interval));
636
+ }
637
+ }
618
638
  Ok(None) if too_large_at.is_some() => { terminate(&mut child, pid); return Err(CatalogError::OutputTooLarge); }
619
639
  Ok(None) => { terminate(&mut child, pid); return Err(CatalogError::Timeout); }
620
640
  Err(_) => { terminate(&mut child, pid); return Err(CatalogError::CommandCouldNotBeObserved); }
@@ -642,6 +662,16 @@ pub(crate) fn run_command(
642
662
  output.ok_or(CatalogError::OutputReadFailed)
643
663
  }
644
664
 
665
+ fn catalog_exit_poll_interval(elapsed: Duration) -> Duration {
666
+ [1, 3, 6, 10]
667
+ .into_iter()
668
+ .map(Duration::from_millis)
669
+ .find(|checkpoint| *checkpoint > elapsed)
670
+ .map_or(Duration::from_millis(10), |checkpoint| {
671
+ checkpoint.saturating_sub(elapsed)
672
+ })
673
+ }
674
+
645
675
  fn terminate(child: &mut Child, pid: u32) {
646
676
  #[cfg(unix)]
647
677
  unsafe {
@@ -700,6 +730,151 @@ impl Drop for OwnedTempDir {
700
730
  fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); }
701
731
  }
702
732
 
733
+ #[cfg(all(test, unix))]
734
+ mod command_wait_regressions {
735
+ use super::*;
736
+
737
+ #[derive(Default)]
738
+ struct Observer {
739
+ pid: Option<u32>,
740
+ exits: Vec<Option<i32>>,
741
+ output_timeouts: usize,
742
+ }
743
+
744
+ impl CatalogObserver for Observer {
745
+ fn spawned(&mut self, pid: u32) { self.pid = Some(pid); }
746
+ fn exited(&mut self, status: &ExitStatus) { self.exits.push(status.code()); }
747
+ fn output_timeout(&mut self) { self.output_timeouts += 1; }
748
+ }
749
+
750
+ fn run(
751
+ script: &str,
752
+ input: Option<&[u8]>,
753
+ timeout: Duration,
754
+ max_bytes: u64,
755
+ observer: &mut Observer,
756
+ ) -> Result<Vec<u8>, CatalogError> {
757
+ run_command(
758
+ Path::new("/bin/sh"), &["-c", script], input, None, &[], &[],
759
+ timeout, max_bytes, observer,
760
+ )
761
+ }
762
+
763
+ fn assert_owned_child_gone(observer: &Observer) {
764
+ let pid = observer.pid.unwrap();
765
+ assert!(pid > 1);
766
+ let status = Command::new("/bin/kill")
767
+ .args(["-0", &pid.to_string()])
768
+ .stdout(Stdio::null())
769
+ .stderr(Stdio::null())
770
+ .status()
771
+ .unwrap();
772
+ assert!(!status.success(), "owned child must be reaped");
773
+ }
774
+
775
+ #[test]
776
+ fn eof_and_success_preserve_input_and_raw_output() {
777
+ let mut observer = Observer::default();
778
+ let input = b"catalog\0\xff\n";
779
+ let output = run(
780
+ "exec /bin/cat", Some(input), CATALOG_TIMEOUT, MAX_CATALOG_BYTES, &mut observer,
781
+ ).unwrap();
782
+ assert_eq!(output, input.to_vec());
783
+ assert_eq!(observer.exits, vec![Some(0)]);
784
+ assert_eq!(observer.output_timeouts, 0);
785
+ assert_owned_child_gone(&observer);
786
+ }
787
+
788
+ #[test]
789
+ fn stdout_eof_does_not_hide_a_later_nonzero_exit() {
790
+ let mut observer = Observer::default();
791
+ let error = run(
792
+ "printf 'partial'; exec 1>&-; /bin/sleep 0.03; exit 17",
793
+ None, CATALOG_TIMEOUT, MAX_CATALOG_BYTES, &mut observer,
794
+ ).unwrap_err();
795
+ assert_eq!(error, CatalogError::CommandFailed);
796
+ assert_eq!(observer.exits, vec![Some(17)]);
797
+ assert_eq!(observer.output_timeouts, 0);
798
+ assert_owned_child_gone(&observer);
799
+ }
800
+
801
+ #[test]
802
+ fn stdout_eof_does_not_disable_the_ten_second_process_timeout() {
803
+ let mut observer = Observer::default();
804
+ let started = Instant::now();
805
+ let error = run(
806
+ "exec 1>&-; exec /bin/sleep 30", None, CATALOG_TIMEOUT,
807
+ MAX_CATALOG_BYTES, &mut observer,
808
+ ).unwrap_err();
809
+ let elapsed = started.elapsed();
810
+ assert_eq!(CATALOG_TIMEOUT, Duration::from_secs(10));
811
+ assert_eq!(error, CatalogError::Timeout);
812
+ assert!(elapsed >= CATALOG_TIMEOUT);
813
+ assert!(elapsed < CATALOG_TIMEOUT + Duration::from_secs(2));
814
+ assert!(observer.exits.is_empty());
815
+ assert_eq!(observer.output_timeouts, 0);
816
+ assert_owned_child_gone(&observer);
817
+ }
818
+
819
+ #[test]
820
+ fn exact_one_mib_is_allowed_and_one_more_byte_is_rejected() {
821
+ assert_eq!(MAX_CATALOG_BYTES, 1024 * 1024);
822
+ let mut observer = Observer::default();
823
+ let script = format!("exec /usr/bin/head -c {MAX_CATALOG_BYTES} /dev/zero");
824
+ let output = run(
825
+ &script, None, CATALOG_TIMEOUT, MAX_CATALOG_BYTES, &mut observer,
826
+ ).unwrap();
827
+ assert_eq!(output.len() as u64, MAX_CATALOG_BYTES);
828
+ assert_owned_child_gone(&observer);
829
+ let mut observer = Observer::default();
830
+ let script = format!("exec /usr/bin/head -c {} /dev/zero", MAX_CATALOG_BYTES + 1);
831
+ assert_eq!(
832
+ run(&script, None, CATALOG_TIMEOUT, MAX_CATALOG_BYTES, &mut observer),
833
+ Err(CatalogError::OutputTooLarge)
834
+ );
835
+ assert_owned_child_gone(&observer);
836
+ }
837
+
838
+ #[test]
839
+ fn oversized_output_keeps_one_hundred_millisecond_exit_grace() {
840
+ let mut observer = Observer::default();
841
+ let started = Instant::now();
842
+ let error = run(
843
+ "printf '%65s' x; exec /bin/sleep 30", None, CATALOG_TIMEOUT, 64, &mut observer,
844
+ ).unwrap_err();
845
+ assert_eq!(error, CatalogError::OutputTooLarge);
846
+ assert!(started.elapsed() >= Duration::from_millis(100));
847
+ assert!(started.elapsed() < Duration::from_secs(2));
848
+ assert_owned_child_gone(&observer);
849
+ }
850
+
851
+ #[test]
852
+ fn exited_child_with_inherited_stdout_keeps_output_timeout_observation() {
853
+ let mut observer = Observer::default();
854
+ let error = run(
855
+ "/bin/sleep 30 & exit 0", None, Duration::from_millis(200),
856
+ MAX_CATALOG_BYTES, &mut observer,
857
+ ).unwrap_err();
858
+ assert_eq!(error, CatalogError::Timeout);
859
+ assert_eq!(observer.exits, vec![Some(0)]);
860
+ assert_eq!(observer.output_timeouts, 1);
861
+ assert_owned_child_gone(&observer);
862
+ }
863
+
864
+ #[test]
865
+ fn early_eof_polling_keeps_ten_millisecond_anchor_and_then_old_cadence() {
866
+ let mut elapsed = Duration::ZERO;
867
+ for (sleep_ms, check_ms) in [(1, 1), (2, 3), (3, 6), (4, 10), (10, 20)] {
868
+ let interval = catalog_exit_poll_interval(elapsed);
869
+ assert_eq!(interval, Duration::from_millis(sleep_ms));
870
+ elapsed += interval;
871
+ assert_eq!(elapsed, Duration::from_millis(check_ms));
872
+ }
873
+ assert_eq!(catalog_exit_poll_interval(Duration::from_micros(9500)), Duration::from_micros(500));
874
+ assert_eq!(catalog_exit_poll_interval(Duration::from_millis(50)), Duration::from_millis(10));
875
+ }
876
+ }
877
+
703
878
  #[cfg(test)]
704
879
  mod tests {
705
880
  use super::*;
@@ -173,11 +173,14 @@ impl RealCommandRunner {
173
173
  let stdout_thread = std::thread::spawn(move || read_pipe(stdout));
174
174
  let stderr_thread = std::thread::spawn(move || read_pipe(stderr));
175
175
  let deadline = Instant::now() + COMMAND_TIMEOUT;
176
+ // Observe short-lived commands promptly; retain the old cadence for long ones.
177
+ let mut poll_interval = Duration::from_millis(1);
176
178
  let status = loop {
177
179
  if let Some(status) = child.try_wait()? {
178
180
  break status;
179
181
  }
180
- if Instant::now() >= deadline {
182
+ let now = Instant::now();
183
+ if now >= deadline {
181
184
  child.kill()?;
182
185
  child.wait()?;
183
186
  let _ = join_pipe_reader(stdout_thread)?;
@@ -187,7 +190,8 @@ impl RealCommandRunner {
187
190
  format!("{program} exceeded 5s timeout"),
188
191
  ));
189
192
  }
190
- std::thread::sleep(Duration::from_millis(25));
193
+ std::thread::sleep(poll_interval.min(deadline.saturating_duration_since(now)));
194
+ poll_interval = (poll_interval * 2).min(Duration::from_millis(25));
191
195
  };
192
196
  let stdout = join_pipe_reader(stdout_thread)?;
193
197
  let stderr = join_pipe_reader(stderr_thread)?;
@@ -4315,3 +4319,96 @@ fn non_empty(raw: &str) -> Option<&str> {
4315
4319
  // tmux is a Unix concept), the test module stays Unix-only.
4316
4320
  #[cfg(all(test, unix))]
4317
4321
  mod tests;
4322
+
4323
+ #[cfg(all(test, unix))]
4324
+ mod command_runner_regressions {
4325
+ use super::{CommandRunner, RealCommandRunner, COMMAND_TIMEOUT};
4326
+ use std::io::ErrorKind;
4327
+ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
4328
+
4329
+ fn shell(command: &str) -> Vec<String> {
4330
+ vec!["/bin/sh".into(), "-c".into(), command.into()]
4331
+ }
4332
+
4333
+ #[test]
4334
+ fn nonzero_exit_preserves_both_outputs_and_lossy_utf8() {
4335
+ let output = RealCommandRunner
4336
+ .run(&shell("printf 'stdout\\377'; printf 'stderr\\376' >&2; exit 17"))
4337
+ .unwrap();
4338
+ assert!(!output.success);
4339
+ assert_eq!(output.code, Some(17));
4340
+ assert_eq!(output.stdout, "stdout\u{fffd}");
4341
+ assert_eq!(output.stderr, "stderr\u{fffd}");
4342
+ }
4343
+
4344
+ #[test]
4345
+ fn stdin_is_delivered_and_closed_before_waiting_for_exit() {
4346
+ let input = "first line\nsecond λ line\n".repeat(32);
4347
+ let output = RealCommandRunner
4348
+ .run_with_stdin(&["/bin/cat".into()], &input)
4349
+ .unwrap();
4350
+ assert!(output.success);
4351
+ assert_eq!(output.code, Some(0));
4352
+ assert_eq!(output.stdout, input);
4353
+ assert_eq!(output.stderr, "");
4354
+ }
4355
+
4356
+ #[test]
4357
+ fn signal_exit_retains_absent_exit_code() {
4358
+ let output = RealCommandRunner.run(&shell("kill -TERM $$")).unwrap();
4359
+ assert!(!output.success);
4360
+ assert_eq!(output.code, None);
4361
+ }
4362
+
4363
+ #[test]
4364
+ fn drains_stdout_and_stderr_beyond_pipe_capacity() {
4365
+ let output = RealCommandRunner
4366
+ .run(&shell(
4367
+ "i=0; while [ \"$i\" -lt 20000 ]; do printf 'out\\n'; printf 'err\\n' >&2; i=$((i+1)); done",
4368
+ ))
4369
+ .unwrap();
4370
+ assert!(output.success);
4371
+ assert_eq!(output.code, Some(0));
4372
+ assert_eq!(output.stdout, "out\n".repeat(20000));
4373
+ assert_eq!(output.stderr, "err\n".repeat(20000));
4374
+ }
4375
+
4376
+ #[test]
4377
+ fn timeout_keeps_five_second_budget_and_reaps_the_owned_child() {
4378
+ let nonce = SystemTime::now()
4379
+ .duration_since(UNIX_EPOCH)
4380
+ .unwrap()
4381
+ .as_nanos();
4382
+ let pid_path = std::env::temp_dir().join(format!(
4383
+ "team-agent-runner-timeout-{}-{nonce}.pid",
4384
+ std::process::id()
4385
+ ));
4386
+ let file = std::fs::OpenOptions::new()
4387
+ .write(true)
4388
+ .create_new(true)
4389
+ .open(&pid_path)
4390
+ .unwrap();
4391
+ drop(file);
4392
+ // exec keeps one owned PID and closes both pipes when the runner kills it.
4393
+ let mut argv = shell("printf '%s' \"$$\" > \"$1\"; exec /bin/sleep 30");
4394
+ argv.extend([
4395
+ "runner-timeout-test".into(),
4396
+ pid_path.to_string_lossy().into_owned(),
4397
+ ]);
4398
+ let started = Instant::now();
4399
+ let error = RealCommandRunner.run(&argv).unwrap_err();
4400
+ let elapsed = started.elapsed();
4401
+ let pid: i32 = std::fs::read_to_string(&pid_path).unwrap().parse().unwrap();
4402
+ std::fs::remove_file(pid_path).unwrap();
4403
+ assert_eq!(error.kind(), ErrorKind::TimedOut);
4404
+ assert!(elapsed >= COMMAND_TIMEOUT);
4405
+ assert!(elapsed < COMMAND_TIMEOUT + Duration::from_secs(2));
4406
+ assert!(pid > 1);
4407
+ // SAFETY: signal 0 only probes the exact child PID recorded by this fixture.
4408
+ assert_eq!(unsafe { libc::kill(pid, 0) }, -1);
4409
+ assert_eq!(
4410
+ std::io::Error::last_os_error().raw_os_error(),
4411
+ Some(libc::ESRCH)
4412
+ );
4413
+ }
4414
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.5.108",
3
+ "version": "0.5.109",
4
4
  "description": "npx installer for Team Agent",
5
5
  "keywords": [
6
6
  "codex",
@@ -20,9 +20,9 @@
20
20
  "team-agent-installer": "npm/install.mjs"
21
21
  },
22
22
  "optionalDependencies": {
23
- "@team-agent/cli-darwin-arm64": "0.5.108",
24
- "@team-agent/cli-darwin-x64": "0.5.108",
25
- "@team-agent/cli-linux-x64": "0.5.108"
23
+ "@team-agent/cli-darwin-arm64": "0.5.109",
24
+ "@team-agent/cli-darwin-x64": "0.5.109",
25
+ "@team-agent/cli-linux-x64": "0.5.109"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",