@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
package/Cargo.toml
CHANGED
|
@@ -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
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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
|
-
|
|
597
|
+
fields: [&str; 5],
|
|
572
598
|
endpoint: &str,
|
|
573
599
|
registered: &[RegisteredNode],
|
|
600
|
+
processes: &BTreeMap<u32, ProcessSnapshot>,
|
|
574
601
|
) -> Option<ProbeNode> {
|
|
575
|
-
let
|
|
576
|
-
let session =
|
|
577
|
-
let window =
|
|
578
|
-
let pane =
|
|
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
|
|
591
|
-
let snapshot =
|
|
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
|
|
624
|
-
let
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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
|
-
|
|
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", ®istered, |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", ®istered, |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", ®istered, |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", ®istered, |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(®istered[0], Some(&failed)).0.health, "unknown");
|
|
1490
|
+
let live = Some(second);
|
|
1491
|
+
assert_eq!(project_node_with_attach(®istered[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", ®istered,
|
|
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 =>
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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.
|
|
24
|
-
"@team-agent/cli-darwin-x64": "0.5.
|
|
25
|
-
"@team-agent/cli-linux-x64": "0.5.
|
|
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",
|