mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
Merge pull request #830 from l0ng-ai/fix/731-port-probe-honesty
fix(procinfo): stop the listening-port probe failing silently (#731)
This commit is contained in:
@@ -3251,6 +3251,7 @@ mod tests {
|
||||
tty7_core::daemon::protocol::PaneProcs {
|
||||
procs: vec![proc_entry(100, "zsh", 0, true)],
|
||||
ports: Vec::new(),
|
||||
probe: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3262,6 +3263,7 @@ mod tests {
|
||||
proc_entry(101, "cargo", 1, true),
|
||||
],
|
||||
ports: Vec::new(),
|
||||
probe: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ use tty7_core::core::machine::{Machine, PaneNode, Workspace};
|
||||
use tty7_core::core::session::WorkspaceId;
|
||||
use tty7_core::core::tab_view::{TabLabel, TabView, strip_host_prefix, tab_views_of};
|
||||
use tty7_core::daemon::control::{PaneAgentState, RouteInfo, ServerStatus};
|
||||
use tty7_core::daemon::protocol::{PaneInfo, PaneProcs};
|
||||
use tty7_core::daemon::protocol::{PaneInfo, PaneProcs, PortProbe};
|
||||
|
||||
use crate::resolve;
|
||||
|
||||
@@ -236,9 +236,34 @@ fn render_node(out: &mut String, node: &PaneNode, machine: &Machine, depth: usiz
|
||||
}
|
||||
}
|
||||
|
||||
/// What the port probe has to say for itself, when it has something to say.
|
||||
///
|
||||
/// This is the line that makes `tty7 procs` a diagnostic rather than another
|
||||
/// place to read an empty PORTS table. An empty table means "nothing is
|
||||
/// listening" only when the probe actually ran, and until it said so there was
|
||||
/// no way — from a screenshot, from a bug report, from the JSON — to tell that
|
||||
/// case from a probe that never got off the ground.
|
||||
fn probe_note(probe: &PortProbe) -> Option<String> {
|
||||
match probe {
|
||||
PortProbe::Ok => None,
|
||||
PortProbe::Restricted => Some(
|
||||
"note: some processes here belong to another user; a probe running as you \
|
||||
cannot see their sockets"
|
||||
.to_string(),
|
||||
),
|
||||
PortProbe::Unavailable(detail) => Some(format!(
|
||||
"note: could not check for listening ports ({detail})"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn procs_tables(procs: &PaneProcs) -> String {
|
||||
let note = probe_note(&procs.probe);
|
||||
if procs.procs.is_empty() && procs.ports.is_empty() {
|
||||
return "nothing running in this pane\n".to_string();
|
||||
return match note {
|
||||
Some(note) => format!("nothing running in this pane\n{note}\n"),
|
||||
None => "nothing running in this pane\n".to_string(),
|
||||
};
|
||||
}
|
||||
let rows: Vec<Vec<String>> = procs
|
||||
.procs
|
||||
@@ -261,6 +286,11 @@ pub fn procs_tables(procs: &PaneProcs) -> String {
|
||||
.collect();
|
||||
out.push_str(&table(&["PORT", "PID", "NAME"], &rows));
|
||||
}
|
||||
if let Some(note) = note {
|
||||
out.push('\n');
|
||||
out.push_str(¬e);
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -551,6 +581,7 @@ mod tests {
|
||||
addr: "*".into(),
|
||||
name: "node".into(),
|
||||
}],
|
||||
probe: PortProbe::Ok,
|
||||
};
|
||||
let rendered = procs_tables(&procs);
|
||||
assert!(
|
||||
@@ -562,5 +593,54 @@ mod tests {
|
||||
"the foreground process is marked: {rendered}"
|
||||
);
|
||||
assert!(rendered.contains("3000"), "{rendered}");
|
||||
assert!(
|
||||
!rendered.contains("note:"),
|
||||
"a probe that worked says nothing: {rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
/// #731's diagnostic. Someone whose ports are missing runs `tty7 procs`
|
||||
/// and pastes what it says; an empty PORTS table is only worth pasting if
|
||||
/// it distinguishes a quiet pane from a probe that never ran.
|
||||
#[test]
|
||||
fn a_probe_that_could_not_run_says_so_next_to_the_empty_table() {
|
||||
let mut procs = PaneProcs {
|
||||
procs: vec![ProcEntry {
|
||||
pid: 100,
|
||||
name: "zsh".into(),
|
||||
depth: 0,
|
||||
foreground: true,
|
||||
}],
|
||||
ports: Vec::new(),
|
||||
probe: PortProbe::Unavailable("lsof: program not found".into()),
|
||||
};
|
||||
let rendered = procs_tables(&procs);
|
||||
assert!(
|
||||
rendered.contains("could not check for listening ports"),
|
||||
"{rendered}"
|
||||
);
|
||||
assert!(
|
||||
rendered.contains("lsof: program not found"),
|
||||
"the reason is the whole point of the line: {rendered}"
|
||||
);
|
||||
|
||||
procs.probe = PortProbe::Restricted;
|
||||
assert!(
|
||||
procs_tables(&procs).contains("another user"),
|
||||
"{}",
|
||||
procs_tables(&procs)
|
||||
);
|
||||
|
||||
// And on a pane with nothing running at all, where the tables are not
|
||||
// drawn, the note still has to come out.
|
||||
let empty = PaneProcs {
|
||||
probe: PortProbe::Unavailable("lsof: program not found".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
procs_tables(&empty).contains("could not check"),
|
||||
"{}",
|
||||
procs_tables(&empty)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,187 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::daemon::protocol::{PaneProcs, PortEntry, ProcEntry};
|
||||
use crate::daemon::protocol::{PaneProcs, PortEntry, PortProbe, ProcEntry};
|
||||
|
||||
const MAX_DEPTH: u8 = 6;
|
||||
|
||||
/// How many processes the panel is asked to draw.
|
||||
const MAX_PROCS: usize = 64;
|
||||
|
||||
/// How far the walk itself goes before it calls the tree pathological.
|
||||
///
|
||||
/// This is deliberately far above `MAX_PROCS`, and the gap is the point. The
|
||||
/// walk is depth-first over children sorted by ascending pid, so capping it at
|
||||
/// the number of rows the panel wants meant one busy early branch — a build,
|
||||
/// a container runtime, an agent's worker pool — could consume the whole
|
||||
/// budget before the traversal ever reached the pane's newest child. A server
|
||||
/// someone just started is the *last* pid in that ordering, so the one process
|
||||
/// the Ports section exists for was the one most likely to fall off the end,
|
||||
/// and it fell off silently. The port probe is asked about everything the walk
|
||||
/// found; only the list handed to the panel is cut back to `MAX_PROCS`.
|
||||
const MAX_TREE: usize = 512;
|
||||
|
||||
pub fn snapshot(shell_pid: u32, fg_pgid: Option<i32>) -> PaneProcs {
|
||||
let table = process_table();
|
||||
let procs = walk(&table, shell_pid, fg_pgid);
|
||||
let ports = listening_ports(&procs);
|
||||
PaneProcs { procs, ports }
|
||||
let (ports, probe) = listening_ports(&procs);
|
||||
let probe = match probe.is_ok() && tree_has_foreign_uid(&table, &procs, current_uid()) {
|
||||
true => PortProbe::Restricted,
|
||||
false => probe,
|
||||
};
|
||||
if let PortProbe::Unavailable(detail) = &probe {
|
||||
note_probe_failure(shell_pid, detail);
|
||||
}
|
||||
finish(procs, ports, probe)
|
||||
}
|
||||
|
||||
/// How long the same probe failure waits before it is written down again.
|
||||
///
|
||||
/// `snapshot` answers one `QueryProcs`, and the Info panel sends one every two
|
||||
/// seconds for as long as it is open — a probe that cannot run now will not
|
||||
/// have started working two seconds later. Logging every attempt turns one
|
||||
/// standing fact into thirty lines a minute in a file that truncates itself at
|
||||
/// 4 MiB, which costs the reporter the rest of the session they turned logging
|
||||
/// on to capture. Once a minute still leaves a trail for a failure that
|
||||
/// outlives the panel.
|
||||
const PROBE_LOG_GAP: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
|
||||
fn note_probe_failure(shell_pid: u32, detail: &str) {
|
||||
static LAST: std::sync::Mutex<Option<(String, std::time::Instant)>> =
|
||||
std::sync::Mutex::new(None);
|
||||
let line = format!("listening-port probe failed for pane shell {shell_pid}: {detail}");
|
||||
let Ok(mut last) = LAST.lock() else { return };
|
||||
if probe_log_due(&mut last, &line, std::time::Instant::now(), PROBE_LOG_GAP) {
|
||||
log::warn!("{line}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `line` is worth a log entry now, given what was written last.
|
||||
///
|
||||
/// A line that has not been said before is always worth saying — a probe that
|
||||
/// starts failing for a second reason, or a second pane failing for the same
|
||||
/// one, is news. Repeating one is worth it only once per `gap`.
|
||||
fn probe_log_due(
|
||||
last: &mut Option<(String, std::time::Instant)>,
|
||||
line: &str,
|
||||
now: std::time::Instant,
|
||||
gap: std::time::Duration,
|
||||
) -> bool {
|
||||
if let Some((said, at)) = last.as_ref() {
|
||||
if said == line && now.duration_since(*at) < gap {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
*last = Some((line.to_string(), now));
|
||||
true
|
||||
}
|
||||
|
||||
/// The answer as the panel gets it: the process list trimmed to what a sidebar
|
||||
/// can show, and the ports left whole.
|
||||
///
|
||||
/// Trimming here rather than in `walk` is what keeps a port owned by the 100th
|
||||
/// process in the tree on screen — the row names its owner out of the full
|
||||
/// list, so cutting the list afterwards costs the panel a process row it had
|
||||
/// no room for and costs the Ports section nothing.
|
||||
fn finish(mut procs: Vec<ProcEntry>, ports: Vec<PortEntry>, probe: PortProbe) -> PaneProcs {
|
||||
procs.truncate(MAX_PROCS);
|
||||
PaneProcs {
|
||||
procs,
|
||||
ports,
|
||||
probe,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any process in the pane's tree belongs to a user other than `me`.
|
||||
///
|
||||
/// `sudo go run main.go` is the shape this is about. The process tree still
|
||||
/// walks — the kernel will name another user's processes — but the sockets
|
||||
/// they hold are readable only by their owner or by root, so `lsof` running as
|
||||
/// this user answers "nothing is listening" about a server that plainly is.
|
||||
/// Saying "some of these are another user's" is the difference between a panel
|
||||
/// that is wrong and a panel that is honest.
|
||||
fn tree_has_foreign_uid(table: &HashMap<u32, Row>, procs: &[ProcEntry], me: u32) -> bool {
|
||||
// Root sees everyone's sockets, so nothing is hidden from a daemon that is
|
||||
// already root and there is nothing to warn about.
|
||||
if me == 0 {
|
||||
return false;
|
||||
}
|
||||
procs.iter().any(|p| {
|
||||
table
|
||||
.get(&p.pid)
|
||||
.is_some_and(|row| row.uid != me && confirm_foreign(p.pid, me))
|
||||
})
|
||||
}
|
||||
|
||||
/// A second opinion on a row that looks like another user's.
|
||||
///
|
||||
/// Asked only about the pane's own tree, and only about the rows that already
|
||||
/// look foreign, so an ordinary pane pays nothing for it and a `sudo` pays one
|
||||
/// file read.
|
||||
///
|
||||
/// Linux needs it because the owner of `/proc/<pid>` is not always the process's
|
||||
/// uid: the kernel makes that directory `root:root` whenever a process's
|
||||
/// dumpable attribute has been cleared, which is what executing a set-user-ID
|
||||
/// binary or one carrying file capabilities does. A plain `ping` in a pane is
|
||||
/// the user's own process behind a root-owned `/proc` entry, and taking the
|
||||
/// directory's word for it would have the panel apologise for sockets it can
|
||||
/// read perfectly well — the opposite mistake to the one this file is fixing,
|
||||
/// and just as wrong. `Uid:` in `/proc/<pid>/status` is the real answer and is
|
||||
/// readable either way.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn confirm_foreign(pid: u32, me: u32) -> bool {
|
||||
match std::fs::read_to_string(format!("/proc/{pid}/status")) {
|
||||
Ok(text) => status_euid(&text).is_none_or(|uid| uid != me),
|
||||
// Gone, or unreadable: keep the directory's verdict rather than
|
||||
// inventing a second one out of a failed read.
|
||||
Err(_) => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// macOS reads the effective uid straight out of `PROC_PIDTBSDINFO`, and
|
||||
/// Windows has no uid to be wrong about, so there is nothing to confirm.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn confirm_foreign(_pid: u32, _me: u32) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// The effective uid on the `Uid:` line of a `/proc/<pid>/status`, which reads
|
||||
/// `Uid:\t<real>\t<effective>\t<saved>\t<filesystem>`.
|
||||
///
|
||||
/// The effective one is the second: it is the credential the kernel checks when
|
||||
/// something asks to read the process's sockets.
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
fn status_euid(text: &str) -> Option<u32> {
|
||||
text.lines()
|
||||
.find_map(|line| line.strip_prefix("Uid:"))?
|
||||
.split_whitespace()
|
||||
.nth(1)?
|
||||
.parse()
|
||||
.ok()
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn current_uid() -> u32 {
|
||||
// SAFETY: `getuid` reads the calling process's own credentials and cannot
|
||||
// fail.
|
||||
unsafe { libc::getuid() }
|
||||
}
|
||||
|
||||
/// Windows has no uid, and its port probe is a kernel table rather than a
|
||||
/// subprocess with an identity — `Row::uid` is 0 there and so is this, so the
|
||||
/// check above is a constant false.
|
||||
#[cfg(not(unix))]
|
||||
fn current_uid() -> u32 {
|
||||
0
|
||||
}
|
||||
|
||||
struct Row {
|
||||
ppid: u32,
|
||||
pgid: u32,
|
||||
/// The effective uid of the process, which is what decides whether this
|
||||
/// daemon may look at its sockets. 0 on platforms that have no such thing,
|
||||
/// and on Linux the cheapest reading of it rather than the last word — see
|
||||
/// `confirm_foreign`.
|
||||
uid: u32,
|
||||
name: String,
|
||||
}
|
||||
|
||||
@@ -32,7 +198,7 @@ fn walk(table: &HashMap<u32, Row>, shell_pid: u32, fg_pgid: Option<i32>) -> Vec<
|
||||
let mut stack = vec![(shell_pid, 0u8)];
|
||||
while let Some((pid, depth)) = stack.pop() {
|
||||
let Some(row) = table.get(&pid) else { continue };
|
||||
if out.len() >= MAX_PROCS {
|
||||
if out.len() >= MAX_TREE {
|
||||
break;
|
||||
}
|
||||
out.push(ProcEntry {
|
||||
@@ -96,6 +262,10 @@ fn process_table() -> HashMap<u32, Row> {
|
||||
Row {
|
||||
ppid: info.pbi_ppid,
|
||||
pgid: info.pbi_pgid,
|
||||
// The effective uid, not the real one: a setuid `sudo` still
|
||||
// runs as the user who typed it, and it is the effective uid
|
||||
// that decides whose sockets `lsof` may read.
|
||||
uid: info.pbi_uid,
|
||||
name,
|
||||
},
|
||||
);
|
||||
@@ -146,7 +316,26 @@ fn process_table() -> HashMap<u32, Row> {
|
||||
.rfind('(')
|
||||
.map_or_else(|| String::new(), |open| stat[open + 1..close].to_string())
|
||||
});
|
||||
table.insert(pid, Row { ppid, pgid, name });
|
||||
// `/proc/<pid>` is normally owned by the process's effective uid, which
|
||||
// is the one that governs who may read its sockets. Normally: the
|
||||
// kernel hands the directory to `root` for a process whose dumpable
|
||||
// attribute it cleared, so this is a cheap first pass over every pid on
|
||||
// the machine and `confirm_foreign` settles the few that look foreign
|
||||
// and are in a pane's tree. A stat that fails on a pid whose `stat`
|
||||
// file just parsed is a race with the process exiting; calling that
|
||||
// "mine" keeps a dying process from being mistaken for another user's.
|
||||
let uid = std::fs::metadata(format!("/proc/{pid}"))
|
||||
.map(|m| std::os::unix::fs::MetadataExt::uid(&m))
|
||||
.unwrap_or_else(|_| current_uid());
|
||||
table.insert(
|
||||
pid,
|
||||
Row {
|
||||
ppid,
|
||||
pgid,
|
||||
uid,
|
||||
name,
|
||||
},
|
||||
);
|
||||
}
|
||||
table
|
||||
}
|
||||
@@ -161,6 +350,7 @@ fn process_table() -> HashMap<u32, Row> {
|
||||
Row {
|
||||
ppid: p.parent,
|
||||
pgid: 0,
|
||||
uid: 0,
|
||||
name: p.name,
|
||||
},
|
||||
)
|
||||
@@ -221,20 +411,42 @@ pub(super) fn proc_name(pid: i32) -> Option<String> {
|
||||
(!comm.is_empty()).then(|| comm.to_string())
|
||||
}
|
||||
|
||||
/// Where to look for `lsof`, in order.
|
||||
///
|
||||
/// `PATH` first, and then the two absolute paths it actually lives at, because
|
||||
/// the daemon's `PATH` is not the shell's. macOS ships `lsof` in `/usr/sbin`,
|
||||
/// which is on the default login `PATH` and is exactly the kind of entry a
|
||||
/// hand-written `export PATH=...` in a dotfile drops on the floor — and the
|
||||
/// daemon inherits whatever the app that launched it had. Falling back to the
|
||||
/// absolute path costs one failed `execvp` in the case that used to end with
|
||||
/// the panel quietly claiming nothing was listening.
|
||||
#[cfg(unix)]
|
||||
fn listening_ports(procs: &[ProcEntry]) -> Vec<PortEntry> {
|
||||
use std::process::{Command, Stdio};
|
||||
const LSOF_CANDIDATES: [&str; 3] = ["lsof", "/usr/sbin/lsof", "/usr/bin/lsof"];
|
||||
|
||||
/// How long the probe gets before it is declared hung.
|
||||
///
|
||||
/// `lsof` is famous for blocking on a wedged network mount, and this one runs
|
||||
/// on the daemon's connection thread: without a bound, one stuck call does not
|
||||
/// merely lose a port, it stops the pane answering `QueryProcs` at all, for as
|
||||
/// long as the mount stays wedged. The Info panel re-polls every two seconds,
|
||||
/// so a probe still running after three has already missed its slot.
|
||||
#[cfg(unix)]
|
||||
const PROBE_BUDGET: std::time::Duration = std::time::Duration::from_secs(3);
|
||||
|
||||
#[cfg(unix)]
|
||||
fn listening_ports(procs: &[ProcEntry]) -> (Vec<PortEntry>, PortProbe) {
|
||||
if procs.is_empty() {
|
||||
return Vec::new();
|
||||
return (Vec::new(), PortProbe::Ok);
|
||||
}
|
||||
let pid_list = procs
|
||||
.iter()
|
||||
.map(|p| p.pid.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let out = Command::new("lsof")
|
||||
.args([
|
||||
let mut first_err = String::new();
|
||||
for tool in LSOF_CANDIDATES {
|
||||
let mut cmd = std::process::Command::new(tool);
|
||||
cmd.args([
|
||||
"-nP",
|
||||
"-iTCP",
|
||||
"-sTCP:LISTEN",
|
||||
@@ -242,13 +454,113 @@ fn listening_ports(procs: &[ProcEntry]) -> Vec<PortEntry> {
|
||||
"-p",
|
||||
&pid_list,
|
||||
"-Fpn",
|
||||
])
|
||||
.stdin(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.output();
|
||||
let Ok(out) = out else { return Vec::new() };
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
]);
|
||||
match run_bounded(cmd, PROBE_BUDGET) {
|
||||
Ok(Run::Finished(out)) => {
|
||||
let ports = parse_lsof(&String::from_utf8_lossy(&out.stdout), procs);
|
||||
// The exit status is deliberately not read as failure. `lsof`
|
||||
// returns 1 for a pid it could not locate, and a pane's tree
|
||||
// grows and loses processes between the walk and this call as
|
||||
// a matter of course — treating that as a broken probe would
|
||||
// put a doubt on screen every time a `ls` finished. What the
|
||||
// status is worth is a log line when the run both complained
|
||||
// and came back with nothing.
|
||||
if ports.is_empty() && !out.status.success() {
|
||||
if let Some(line) = String::from_utf8_lossy(&out.stderr)
|
||||
.lines()
|
||||
.find(|l| !l.trim().is_empty())
|
||||
{
|
||||
log::debug!("{tool} found no listeners and said: {line}");
|
||||
}
|
||||
}
|
||||
return (ports, PortProbe::Ok);
|
||||
}
|
||||
Ok(Run::TimedOut) => {
|
||||
return (
|
||||
Vec::new(),
|
||||
PortProbe::Unavailable(format!(
|
||||
"{tool} did not answer within {}s",
|
||||
PROBE_BUDGET.as_secs()
|
||||
)),
|
||||
);
|
||||
}
|
||||
// Try the next candidate: this one is not there, or is not
|
||||
// runnable. The complaint kept is the first one, about the name as
|
||||
// the daemon's `PATH` sees it, since that is the failure worth
|
||||
// reading — the others are fallbacks nobody asked for.
|
||||
Err(e) => {
|
||||
if first_err.is_empty() {
|
||||
first_err = format!("{tool}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(
|
||||
Vec::new(),
|
||||
PortProbe::Unavailable(format!(
|
||||
"{first_err} (also tried {})",
|
||||
LSOF_CANDIDATES[1..].join(", ")
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
/// What became of a probe process.
|
||||
///
|
||||
/// Compiled everywhere and used by the unix probe and by the tests, which is
|
||||
/// how a parser and a timeout that only ever run on macOS and Linux get
|
||||
/// exercised on a Windows machine.
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
enum Run {
|
||||
Finished(std::process::Output),
|
||||
TimedOut,
|
||||
}
|
||||
|
||||
/// Run `cmd` to completion, or kill it once `budget` is up.
|
||||
///
|
||||
/// `Command::output` has no deadline, and the caller is a daemon thread that a
|
||||
/// hung child would own forever. Both pipes are read after the wait rather
|
||||
/// than while it runs, which is safe for a probe whose whole output is a few
|
||||
/// hundred bytes and which is killed if it ever stops making progress.
|
||||
///
|
||||
/// A killed run is reaped and its output abandoned unread. Reading it would
|
||||
/// reintroduce the hang this exists to prevent: a child that spawned something
|
||||
/// of its own hands the write end of the pipe on, and waiting for end-of-file
|
||||
/// then means waiting for a grandchild nobody killed.
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
fn run_bounded(
|
||||
mut cmd: std::process::Command,
|
||||
budget: std::time::Duration,
|
||||
) -> std::io::Result<Run> {
|
||||
use std::process::Stdio;
|
||||
|
||||
let mut child = cmd
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()?;
|
||||
let deadline = std::time::Instant::now() + budget;
|
||||
loop {
|
||||
if child.try_wait()?.is_some() {
|
||||
return Ok(Run::Finished(child.wait_with_output()?));
|
||||
}
|
||||
if std::time::Instant::now() >= deadline {
|
||||
let _ = child.kill();
|
||||
// Reaps the process this call started, so a timed-out probe leaves
|
||||
// no zombie behind; the pipes close as `child` drops.
|
||||
let _ = child.wait();
|
||||
return Ok(Run::TimedOut);
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
/// The listeners in one `lsof -Fpn` report, named after the processes that own
|
||||
/// them.
|
||||
///
|
||||
/// Split out from the call so the format can be tested off a Mac: this parser
|
||||
/// is the half of the probe that has no platform in it.
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
fn parse_lsof(text: &str, procs: &[ProcEntry]) -> Vec<PortEntry> {
|
||||
let by_pid: HashMap<u32, &str> = procs.iter().map(|p| (p.pid, p.name.as_str())).collect();
|
||||
let mut ports: Vec<PortEntry> = Vec::new();
|
||||
let mut current = 0u32;
|
||||
@@ -262,32 +574,13 @@ fn listening_ports(procs: &[ProcEntry]) -> Vec<PortEntry> {
|
||||
let Some((addr, port)) = parse_listen_addr(rest) else {
|
||||
continue;
|
||||
};
|
||||
// One process listening on the same port over IPv4 and IPv6 is
|
||||
// one port to show. Which of the two lines survives used to be
|
||||
// whichever lsof printed first; now that the address is carried
|
||||
// through to a clickable URL, the reachable one wins — a
|
||||
// process bound to both `192.168.1.5` and `*` is on localhost,
|
||||
// and the row should say so.
|
||||
if let Some(seen) = ports
|
||||
.iter_mut()
|
||||
.find(|e| e.port == port && e.pid == current)
|
||||
{
|
||||
if !PortEntry::reaches_loopback(&seen.addr) && PortEntry::reaches_loopback(addr)
|
||||
{
|
||||
seen.addr = addr.to_string();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
ports.push(PortEntry {
|
||||
record_listener(
|
||||
&mut ports,
|
||||
by_pid.get(¤t).copied().unwrap_or_default(),
|
||||
port,
|
||||
pid: current,
|
||||
addr: addr.to_string(),
|
||||
name: by_pid
|
||||
.get(¤t)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
});
|
||||
current,
|
||||
addr.to_string(),
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -311,8 +604,15 @@ fn listening_ports(procs: &[ProcEntry]) -> Vec<PortEntry> {
|
||||
/// second call when its table outgrew that. The Info tab re-polls every two
|
||||
/// seconds while it is open, so this is a fixed handful of microseconds, with
|
||||
/// no process spawn and nothing allocated per pid.
|
||||
///
|
||||
/// There is no tool to be missing here and no subprocess to hang, so the state
|
||||
/// is `Ok` whenever the kernel answered at all — including for a machine with
|
||||
/// IPv6 off, which is answered for by its IPv4 table alone. The one case left
|
||||
/// is a `GetExtendedTcpTable` that would not answer for either family, and that
|
||||
/// is `Unavailable` for the same reason a missing `lsof` is: nothing looked, so
|
||||
/// the empty list is not an answer.
|
||||
#[cfg(windows)]
|
||||
fn listening_ports(procs: &[ProcEntry]) -> Vec<PortEntry> {
|
||||
fn listening_ports(procs: &[ProcEntry]) -> (Vec<PortEntry>, PortProbe) {
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
|
||||
use windows_sys::Win32::NetworkManagement::IpHelper::{
|
||||
@@ -321,18 +621,19 @@ fn listening_ports(procs: &[ProcEntry]) -> Vec<PortEntry> {
|
||||
};
|
||||
|
||||
if procs.is_empty() {
|
||||
return Vec::new();
|
||||
return (Vec::new(), PortProbe::Ok);
|
||||
}
|
||||
let by_pid: HashMap<u32, &str> = procs.iter().map(|p| (p.pid, p.name.as_str())).collect();
|
||||
let mut ports: Vec<PortEntry> = Vec::new();
|
||||
|
||||
let v4 = tcp_table(AF_INET);
|
||||
// SAFETY: `tcp_table` hands back either an empty buffer or one the kernel
|
||||
// filled with a `MIB_TCPTABLE_OWNER_PID`; the `Vec<u32>` gives it the 4-byte
|
||||
// alignment every field of that struct wants, and `rows` is clamped to what
|
||||
// the buffer can actually hold before anything is read out of it.
|
||||
// SAFETY: `tcp_table` hands back a buffer the kernel filled with a
|
||||
// `MIB_TCPTABLE_OWNER_PID`, or nothing at all; the `Vec<u32>` gives it the
|
||||
// 4-byte alignment every field of that struct wants, and `rows` is clamped
|
||||
// to what the buffer can actually hold before anything is read out of it.
|
||||
unsafe {
|
||||
if let Some((rows, count)) = table_rows::<MIB_TCPTABLE_OWNER_PID, MIB_TCPROW_OWNER_PID>(&v4)
|
||||
if let Some((rows, count)) =
|
||||
table_rows::<MIB_TCPTABLE_OWNER_PID, MIB_TCPROW_OWNER_PID>(v4.as_deref().unwrap_or(&[]))
|
||||
{
|
||||
for i in 0..count {
|
||||
let row = &*rows.add(i);
|
||||
@@ -357,9 +658,9 @@ fn listening_ports(procs: &[ProcEntry]) -> Vec<PortEntry> {
|
||||
let v6 = tcp_table(AF_INET6);
|
||||
// SAFETY: as above, for the IPv6 shape of the same table.
|
||||
unsafe {
|
||||
if let Some((rows, count)) =
|
||||
table_rows::<MIB_TCP6TABLE_OWNER_PID, MIB_TCP6ROW_OWNER_PID>(&v6)
|
||||
{
|
||||
if let Some((rows, count)) = table_rows::<MIB_TCP6TABLE_OWNER_PID, MIB_TCP6ROW_OWNER_PID>(
|
||||
v6.as_deref().unwrap_or(&[]),
|
||||
) {
|
||||
for i in 0..count {
|
||||
let row = &*rows.add(i);
|
||||
let Some(name) = by_pid.get(&row.dwOwningPid) else {
|
||||
@@ -378,7 +679,25 @@ fn listening_ports(procs: &[ProcEntry]) -> Vec<PortEntry> {
|
||||
}
|
||||
|
||||
ports.sort_by_key(|e| (e.port, e.pid));
|
||||
ports
|
||||
(ports, windows_probe(v4.is_some(), v6.is_some()))
|
||||
}
|
||||
|
||||
/// What a Windows probe is worth, given whether each family's table could be
|
||||
/// read.
|
||||
///
|
||||
/// One family refusing is not a broken probe: a machine with IPv6 switched off
|
||||
/// is an ordinary machine, and its IPv4 listeners are the whole truth about it.
|
||||
/// Both refusing is the failure this file is about — nothing was looked at, and
|
||||
/// an empty list then means "we do not know", which is the one thing #731 says
|
||||
/// the panel must not spell as "None".
|
||||
#[cfg(windows)]
|
||||
fn windows_probe(v4: bool, v6: bool) -> PortProbe {
|
||||
match v4 || v6 {
|
||||
true => PortProbe::Ok,
|
||||
false => PortProbe::Unavailable(
|
||||
"GetExtendedTcpTable would not answer for either address family".to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// The two Winsock address families, named here rather than by switching on
|
||||
@@ -390,13 +709,17 @@ const AF_INET: u32 = 2;
|
||||
const AF_INET6: u32 = 23;
|
||||
|
||||
/// One `GetExtendedTcpTable` snapshot of the listening sockets in `family`, as
|
||||
/// the raw buffer the kernel filled, or an empty buffer if it would not answer.
|
||||
/// the raw buffer the kernel filled, or `None` if it would not answer.
|
||||
///
|
||||
/// Failure is soft, the way an absent `lsof` is soft on unix: the panel shows no
|
||||
/// ports rather than an error. A machine with IPv6 disabled takes that path for
|
||||
/// `AF_INET6` alone and still gets its IPv4 ports.
|
||||
/// A machine with IPv6 disabled takes the `None` path for `AF_INET6` alone and
|
||||
/// still gets its IPv4 ports. What `None` must not do is disappear: it used to
|
||||
/// come back as an empty buffer that read exactly like a family with no
|
||||
/// listeners, so a table the kernel refused twice — or refused outright, which
|
||||
/// a filter driver sitting on `iphlpapi` is enough to cause — left the panel
|
||||
/// saying "None" about ports it never looked for. The caller turns "neither
|
||||
/// family answered" into `PortProbe::Unavailable` instead.
|
||||
#[cfg(windows)]
|
||||
fn tcp_table(family: u32) -> Vec<u32> {
|
||||
fn tcp_table(family: u32) -> Option<Vec<u32>> {
|
||||
use windows_sys::Win32::Foundation::{ERROR_INSUFFICIENT_BUFFER, NO_ERROR};
|
||||
use windows_sys::Win32::NetworkManagement::IpHelper::{
|
||||
GetExtendedTcpTable, TCP_TABLE_OWNER_PID_LISTENER,
|
||||
@@ -425,14 +748,14 @@ fn tcp_table(family: u32) -> Vec<u32> {
|
||||
)
|
||||
};
|
||||
match rc {
|
||||
NO_ERROR => return buf,
|
||||
NO_ERROR => return Some(buf),
|
||||
ERROR_INSUFFICIENT_BUFFER => {
|
||||
buf = vec![0u32; (size as usize).div_ceil(std::mem::size_of::<u32>()) + 64]
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
None
|
||||
}
|
||||
|
||||
/// Where the rows of a `MIB_*TABLE_OWNER_PID` start in `buf`, and how many of
|
||||
@@ -494,11 +817,11 @@ fn spell_v6(addr: std::net::Ipv6Addr) -> String {
|
||||
/// Adds one listening socket to the list, merging it with a row already there
|
||||
/// for the same port and pid.
|
||||
///
|
||||
/// The merge rule is the unix path's, for the same reason: a process bound to
|
||||
/// both `192.168.1.5` and `*` is on localhost, and the row the panel turns into
|
||||
/// a clickable URL should say so rather than whichever address the kernel
|
||||
/// happened to list first.
|
||||
#[cfg(windows)]
|
||||
/// One rule for both platforms — it was written twice, once inline in the
|
||||
/// `lsof` parser and once here, and two copies of a merge rule is one too
|
||||
/// many. A process bound to both `192.168.1.5` and `*` is on localhost, and
|
||||
/// the row the panel turns into a clickable URL should say so rather than
|
||||
/// keeping whichever address the kernel or `lsof` happened to list first.
|
||||
fn record_listener(ports: &mut Vec<PortEntry>, name: &str, port: u16, pid: u32, addr: String) {
|
||||
if let Some(seen) = ports.iter_mut().find(|e| e.port == port && e.pid == pid) {
|
||||
if !PortEntry::reaches_loopback(&seen.addr) && PortEntry::reaches_loopback(&addr) {
|
||||
@@ -515,8 +838,11 @@ fn record_listener(ports: &mut Vec<PortEntry>, name: &str, port: u16, pid: u32,
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn listening_ports(_procs: &[ProcEntry]) -> Vec<PortEntry> {
|
||||
Vec::new()
|
||||
fn listening_ports(_procs: &[ProcEntry]) -> (Vec<PortEntry>, PortProbe) {
|
||||
(
|
||||
Vec::new(),
|
||||
PortProbe::Unavailable("no port probe on this platform".to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
/// The address and port `lsof -Fn` reports a listener on — `*:3000`,
|
||||
@@ -538,10 +864,14 @@ fn parse_listen_addr(name: &str) -> Option<(&str, u16)> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The uid every fabricated row belongs to unless a test says otherwise.
|
||||
const ME: u32 = 501;
|
||||
|
||||
fn row(ppid: u32, name: &str) -> Row {
|
||||
Row {
|
||||
ppid,
|
||||
pgid: 0,
|
||||
uid: ME,
|
||||
name: name.to_string(),
|
||||
}
|
||||
}
|
||||
@@ -589,7 +919,288 @@ mod tests {
|
||||
.into_iter()
|
||||
.collect();
|
||||
let got = walk(&table, 100, None);
|
||||
assert!(got.len() <= MAX_PROCS, "bounded, not infinite");
|
||||
assert!(got.len() <= MAX_TREE, "bounded, not infinite");
|
||||
}
|
||||
|
||||
/// #731. The walk is depth-first over children in ascending pid order, so
|
||||
/// a shell whose earlier children brought a crowd used to exhaust the row
|
||||
/// budget before the traversal reached the newest child — and the newest
|
||||
/// child, highest pid and visited last, is precisely the `go run` someone
|
||||
/// started ten seconds ago and is looking for the port of.
|
||||
#[test]
|
||||
fn the_newest_child_survives_a_shell_crowded_with_older_ones() {
|
||||
let mut table: HashMap<u32, Row> = [(100, row(1, "zsh"))].into_iter().collect();
|
||||
// 80 older children, each with a child of its own: 160 processes, well
|
||||
// past the 64 the panel draws.
|
||||
for i in 0..80u32 {
|
||||
table.insert(200 + i * 2, row(100, "node"));
|
||||
table.insert(201 + i * 2, row(200 + i * 2, "esbuild"));
|
||||
}
|
||||
table.insert(9000, row(100, "go"));
|
||||
table.insert(9001, row(9000, "main"));
|
||||
|
||||
let walked = walk(&table, 100, None);
|
||||
assert!(
|
||||
walked.iter().any(|p| p.pid == 9001 && p.name == "main"),
|
||||
"the process holding the listener must reach the probe, got {} rows",
|
||||
walked.len()
|
||||
);
|
||||
|
||||
let ports = vec![PortEntry {
|
||||
port: 8080,
|
||||
pid: 9001,
|
||||
addr: "*".into(),
|
||||
name: "main".into(),
|
||||
}];
|
||||
let out = finish(walked, ports, PortProbe::Ok);
|
||||
assert_eq!(
|
||||
out.procs.len(),
|
||||
MAX_PROCS,
|
||||
"the panel still gets a short list"
|
||||
);
|
||||
assert_eq!(
|
||||
out.ports.first().map(|p| p.port),
|
||||
Some(8080),
|
||||
"and the port survives the trim that dropped its owner's row"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pathological_tree_still_stops() {
|
||||
let mut table: HashMap<u32, Row> = [(100, row(1, "zsh"))].into_iter().collect();
|
||||
for pid in 200..2000u32 {
|
||||
table.insert(pid, row(100, "fork-bomb"));
|
||||
}
|
||||
assert_eq!(walk(&table, 100, None).len(), MAX_TREE);
|
||||
}
|
||||
|
||||
/// A `sudo go run` is a server the panel can see and a socket it cannot.
|
||||
/// Saying nothing is listening is the one answer that is certainly wrong.
|
||||
#[test]
|
||||
fn another_users_process_in_the_tree_is_noticed() {
|
||||
let mut table: HashMap<u32, Row> = [
|
||||
(100, row(1, "zsh")),
|
||||
(200, row(100, "sudo")),
|
||||
(300, row(200, "main")),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
let procs = walk(&table, 100, None);
|
||||
assert!(
|
||||
!tree_has_foreign_uid(&table, &procs, ME),
|
||||
"everything is mine until sudo takes over"
|
||||
);
|
||||
|
||||
table.get_mut(&200).unwrap().uid = 0;
|
||||
table.get_mut(&300).unwrap().uid = 0;
|
||||
assert!(tree_has_foreign_uid(&table, &procs, ME));
|
||||
assert!(
|
||||
!tree_has_foreign_uid(&table, &procs, 0),
|
||||
"a daemon already running as root is shown everyone's sockets"
|
||||
);
|
||||
}
|
||||
|
||||
/// The `go run` shape from #731, as `lsof -Fpn` reports it: the shell is in
|
||||
/// the pid list and holds nothing, the compiled binary under `$TMPDIR`
|
||||
/// holds the listener, and it is bound over both address families.
|
||||
#[test]
|
||||
fn parses_a_go_run_report() {
|
||||
let procs = vec![
|
||||
ProcEntry {
|
||||
pid: 100,
|
||||
name: "zsh".into(),
|
||||
depth: 0,
|
||||
foreground: false,
|
||||
},
|
||||
ProcEntry {
|
||||
pid: 9000,
|
||||
name: "go".into(),
|
||||
depth: 1,
|
||||
foreground: true,
|
||||
},
|
||||
ProcEntry {
|
||||
pid: 9001,
|
||||
name: "main".into(),
|
||||
depth: 2,
|
||||
foreground: true,
|
||||
},
|
||||
];
|
||||
let report = "p9001\nf3\nn*:8080\nf5\nn[::]:8080\np100\n";
|
||||
let ports = parse_lsof(report, &procs);
|
||||
assert_eq!(
|
||||
ports.len(),
|
||||
1,
|
||||
"one server, not one row per family: {ports:?}"
|
||||
);
|
||||
assert_eq!(ports[0].port, 8080);
|
||||
assert_eq!(ports[0].pid, 9001);
|
||||
assert_eq!(
|
||||
ports[0].name, "main",
|
||||
"the row names the binary `go run` built, not `go`"
|
||||
);
|
||||
assert_eq!(ports[0].authority(), "localhost:8080");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_report_about_nobody_we_asked_about_still_parses() {
|
||||
// A pid that vanished between the walk and the probe leaves a row with
|
||||
// no name rather than dropping the port someone can still click.
|
||||
let ports = parse_lsof("p4242\nn127.0.0.1:5173\n", &[]);
|
||||
assert_eq!(ports.len(), 1);
|
||||
assert_eq!(ports[0].name, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_quick_probe_comes_back_whole() {
|
||||
let got = run_bounded(echo_command(), std::time::Duration::from_secs(30));
|
||||
let out = match got {
|
||||
Ok(Run::Finished(ref out)) => out,
|
||||
ref other => panic!("expected a finished run, got {}", describe(other)),
|
||||
};
|
||||
assert!(String::from_utf8_lossy(&out.stdout).contains("tty7"));
|
||||
}
|
||||
|
||||
/// The bound is the whole point: this probe runs on the daemon thread that
|
||||
/// answers `QueryProcs`, and an `lsof` wedged on a dead mount used to own
|
||||
/// it forever.
|
||||
///
|
||||
/// The sleeper is deliberately a wrapper around a second process, which is
|
||||
/// the shape that makes this hard: killing the child does not close the
|
||||
/// pipe its own child inherited, so a `wait_with_output` after the kill
|
||||
/// would sit on that pipe for the full sleep and hand the caller a timeout
|
||||
/// that took as long as no timeout at all.
|
||||
#[test]
|
||||
fn a_probe_that_never_finishes_is_killed_and_named() {
|
||||
let cmd = sleeper_command();
|
||||
let started = std::time::Instant::now();
|
||||
let got = run_bounded(cmd, std::time::Duration::from_millis(300));
|
||||
assert!(
|
||||
matches!(got, Ok(Run::TimedOut)),
|
||||
"expected a timeout, got {}",
|
||||
describe(&got)
|
||||
);
|
||||
assert!(
|
||||
started.elapsed() < std::time::Duration::from_secs(10),
|
||||
"the caller was released long before the child would have exited"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_probe_is_an_error_and_not_an_empty_answer() {
|
||||
let cmd = std::process::Command::new("tty7-no-such-probe-b0rk");
|
||||
assert!(
|
||||
run_bounded(cmd, std::time::Duration::from_secs(5)).is_err(),
|
||||
"a tool that is not there has to be distinguishable from one that found nothing"
|
||||
);
|
||||
}
|
||||
|
||||
/// A failure that stands still is one fact, and `snapshot` is asked again
|
||||
/// every two seconds for as long as the Info panel is open.
|
||||
#[test]
|
||||
fn a_standing_probe_failure_is_logged_once_a_minute_not_once_a_poll() {
|
||||
let mut last = None;
|
||||
let t0 = std::time::Instant::now();
|
||||
let gap = std::time::Duration::from_secs(60);
|
||||
let line = "shell 100: lsof: program not found";
|
||||
assert!(
|
||||
probe_log_due(&mut last, line, t0, gap),
|
||||
"the first one talks"
|
||||
);
|
||||
for poll in 1..30u64 {
|
||||
let now = t0 + std::time::Duration::from_secs(poll * 2);
|
||||
assert!(
|
||||
!probe_log_due(&mut last, line, now, gap),
|
||||
"the same reason again at +{}s",
|
||||
poll * 2
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
probe_log_due(&mut last, "shell 100: lsof: permission denied", t0, gap),
|
||||
"a different reason is news even in the same breath"
|
||||
);
|
||||
assert!(
|
||||
probe_log_due(&mut last, line, t0 + gap, gap),
|
||||
"and a failure that outlives the gap still leaves a trail"
|
||||
);
|
||||
}
|
||||
|
||||
/// A `/proc/<pid>` the kernel handed to root is not evidence of another
|
||||
/// user: `ping`, and anything else carrying file capabilities, is the
|
||||
/// caller's own process behind one.
|
||||
#[test]
|
||||
fn the_status_files_effective_uid_is_the_one_that_counts() {
|
||||
let ping = "Name:\tping\nState:\tS (sleeping)\nTgid:\t4242\n\
|
||||
Uid:\t501\t501\t501\t501\nGid:\t20\t20\t20\t20\n";
|
||||
assert_eq!(status_euid(ping), Some(501));
|
||||
|
||||
let sudo = "Name:\tmain\nUid:\t501\t0\t0\t0\n";
|
||||
assert_eq!(
|
||||
status_euid(sudo),
|
||||
Some(0),
|
||||
"the effective uid, not the real one that typed the password"
|
||||
);
|
||||
|
||||
assert_eq!(status_euid("Name:\tzsh\n"), None);
|
||||
assert_eq!(
|
||||
status_euid("Uid:\t501\n"),
|
||||
None,
|
||||
"a truncated line is no answer"
|
||||
);
|
||||
}
|
||||
|
||||
/// Windows has no tool to be missing, but it does have a kernel call that
|
||||
/// can refuse, and a refusal used to arrive as an empty table — the same
|
||||
/// silence #731 is about, on the platform the panel was written on.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn a_windows_table_that_would_not_answer_is_not_an_empty_one() {
|
||||
assert_eq!(windows_probe(true, true), PortProbe::Ok);
|
||||
assert_eq!(
|
||||
windows_probe(true, false),
|
||||
PortProbe::Ok,
|
||||
"IPv6 switched off is an ordinary machine, not a broken probe"
|
||||
);
|
||||
assert!(
|
||||
matches!(windows_probe(false, false), PortProbe::Unavailable(_)),
|
||||
"neither family answered, so the empty list is not an answer"
|
||||
);
|
||||
}
|
||||
|
||||
fn describe(run: &std::io::Result<Run>) -> String {
|
||||
match run {
|
||||
Ok(Run::Finished(out)) => format!("finished with {}", out.status),
|
||||
Ok(Run::TimedOut) => "a timeout".to_string(),
|
||||
Err(e) => format!("an error: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn echo_command() -> std::process::Command {
|
||||
let mut c = std::process::Command::new("cmd");
|
||||
c.args(["/c", "echo", "tty7"]);
|
||||
c
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn echo_command() -> std::process::Command {
|
||||
let mut c = std::process::Command::new("echo");
|
||||
c.arg("tty7");
|
||||
c
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn sleeper_command() -> std::process::Command {
|
||||
// `ping` is the sleep every Windows image has.
|
||||
let mut c = std::process::Command::new("cmd");
|
||||
c.args(["/c", "ping", "-n", "20", "127.0.0.1"]);
|
||||
c
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn sleeper_command() -> std::process::Command {
|
||||
let mut c = std::process::Command::new("sleep");
|
||||
c.arg("60");
|
||||
c
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -779,6 +1390,11 @@ mod windows_tests {
|
||||
"the chain must be walked past its root: {:?}",
|
||||
got.procs
|
||||
);
|
||||
assert!(
|
||||
got.probe.is_ok(),
|
||||
"a kernel that answered has nothing to apologise for: {:?}",
|
||||
got.probe
|
||||
);
|
||||
let found = got
|
||||
.ports
|
||||
.iter()
|
||||
|
||||
@@ -464,10 +464,46 @@ impl PortEntry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the answer in `PaneProcs::ports` can be believed.
|
||||
///
|
||||
/// An empty port list used to mean two very different things at once: nothing
|
||||
/// in this pane is listening, or the thing that looks for listeners never got
|
||||
/// to say. On unix that look is an `lsof` subprocess, and every way it can go
|
||||
/// wrong — absent from the daemon's `PATH`, killed, hung on a wedged mount,
|
||||
/// pointed at sockets it has no permission to read — arrived as the same empty
|
||||
/// vector as a genuinely quiet pane. Whoever reads the list gets to know which
|
||||
/// one it is.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "state", content = "detail", rename_all = "snake_case")]
|
||||
pub enum PortProbe {
|
||||
/// The probe ran and the list is its whole answer.
|
||||
#[default]
|
||||
Ok,
|
||||
/// The probe ran, but at least one process in this pane belongs to another
|
||||
/// user — `sudo go run`, a root-owned server on a low port — and a probe
|
||||
/// running as this user cannot see that process's sockets. The list holds
|
||||
/// what could be seen, which may be nothing.
|
||||
Restricted,
|
||||
/// The probe could not be run at all. The string is for a log line or for
|
||||
/// `tty7 procs`, not for the panel: it names the tool and what went wrong.
|
||||
Unavailable(String),
|
||||
}
|
||||
|
||||
impl PortProbe {
|
||||
pub fn is_ok(&self) -> bool {
|
||||
matches!(self, PortProbe::Ok)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PaneProcs {
|
||||
pub procs: Vec<ProcEntry>,
|
||||
pub ports: Vec<PortEntry>,
|
||||
/// `serde(default)` because a daemon from before this field existed
|
||||
/// answers `QueryProcs` without it, and its silence is read the way that
|
||||
/// daemon's callers read every answer it gives: as a complete one.
|
||||
#[serde(default)]
|
||||
pub probe: PortProbe,
|
||||
}
|
||||
|
||||
fn default_term() -> String {
|
||||
@@ -2192,6 +2228,35 @@ mod tests {
|
||||
assert!(ClientMsg::read(&mut empty2).is_err());
|
||||
}
|
||||
|
||||
/// The port probe's verdict travels over the same wire as the ports, and
|
||||
/// the two ends of that wire are regularly different builds: a remote
|
||||
/// workspace's panes are answered for by whatever `tty7-server` is
|
||||
/// installed on the peer. An older one says nothing about the probe, and
|
||||
/// its silence has to read as "this list is the whole answer" rather than
|
||||
/// failing the frame or, worse, arriving as a doubt the panel then shows.
|
||||
#[test]
|
||||
fn a_procs_answer_without_a_probe_verdict_is_a_complete_one() {
|
||||
let old = r#"{"procs":[],"ports":[{"port":3000,"pid":7,"name":"node"}]}"#;
|
||||
let procs: PaneProcs = serde_json::from_str(old).unwrap();
|
||||
assert_eq!(procs.probe, PortProbe::Ok);
|
||||
assert!(procs.probe.is_ok());
|
||||
assert_eq!(procs.ports[0].addr, "");
|
||||
|
||||
for probe in [
|
||||
PortProbe::Ok,
|
||||
PortProbe::Restricted,
|
||||
PortProbe::Unavailable("lsof: not found".into()),
|
||||
] {
|
||||
let wire = serde_json::to_string(&PaneProcs {
|
||||
probe: probe.clone(),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
let back: PaneProcs = serde_json::from_str(&wire).unwrap();
|
||||
assert_eq!(back.probe, probe, "round trip through {wire}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pane_info_deserializes_with_defaults() {
|
||||
let info: PaneInfo = serde_json::from_str(r#"{"pane_id": 5, "alive": true}"#).unwrap();
|
||||
|
||||
@@ -1711,6 +1711,7 @@ mod aggregate_tests {
|
||||
name: "node".into(),
|
||||
addr: "*".into(),
|
||||
}],
|
||||
probe: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1066,6 +1066,10 @@ pub fn translate_en(key: L10nKey) -> &'static str {
|
||||
L10nKey::PanelProcessesSubtitle => "Processes",
|
||||
L10nKey::PanelPortsSubtitle => "Ports",
|
||||
L10nKey::PanelPortsUnsupported => "That machine's tty7-server is too old to list ports.",
|
||||
L10nKey::PanelPortsProbeFailed => "Couldn't check what this pane is listening on.",
|
||||
L10nKey::PanelPortsRestricted => {
|
||||
"Something here runs as another user, whose ports aren't visible."
|
||||
}
|
||||
L10nKey::PortAutoForwarded => "Remote :{port} is now http://localhost:{local}",
|
||||
L10nKey::PanelCwd => "cwd",
|
||||
L10nKey::PanelShell => "shell",
|
||||
|
||||
@@ -1130,6 +1130,12 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::PanelProcessesSubtitle => "プロセス",
|
||||
L10nKey::PanelPortsSubtitle => "ポート",
|
||||
L10nKey::PanelPortsUnsupported => "リモートの tty7-server が古く、ポートを列挙できません。",
|
||||
L10nKey::PanelPortsProbeFailed => {
|
||||
"このペインが何をリッスンしているか確認できませんでした。"
|
||||
}
|
||||
L10nKey::PanelPortsRestricted => {
|
||||
"他のユーザーで動いているプロセスがあり、そのポートは見えません。"
|
||||
}
|
||||
L10nKey::PortAutoForwarded => "リモートの :{port} は http://localhost:{local} で開けます",
|
||||
L10nKey::PanelCwd => "作業ディレクトリ",
|
||||
L10nKey::PanelShell => "シェル",
|
||||
|
||||
@@ -786,6 +786,8 @@ l10n_keys! {
|
||||
PanelProcessesSubtitle,
|
||||
PanelPortsSubtitle,
|
||||
PanelPortsUnsupported,
|
||||
PanelPortsProbeFailed,
|
||||
PanelPortsRestricted,
|
||||
PortAutoForwarded,
|
||||
PanelCwd,
|
||||
PanelShell,
|
||||
|
||||
@@ -1021,6 +1021,8 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::PanelProcessesSubtitle => "进程",
|
||||
L10nKey::PanelPortsSubtitle => "端口",
|
||||
L10nKey::PanelPortsUnsupported => "对端的 tty7-server 太旧,列不出端口。",
|
||||
L10nKey::PanelPortsProbeFailed => "没能查出这个窗格在监听什么。",
|
||||
L10nKey::PanelPortsRestricted => "这里有以其他用户身份运行的进程,看不到它们的端口。",
|
||||
L10nKey::PortAutoForwarded => "远程 :{port} 现在是 http://localhost:{local}",
|
||||
L10nKey::PanelCwd => "工作目录",
|
||||
L10nKey::PanelShell => "shell",
|
||||
|
||||
+27
-14
@@ -8,7 +8,7 @@ use gpui_component::{
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::core::config::{Config, RightPanelTab};
|
||||
use crate::daemon::protocol::{ManagedForward, PaneProcs};
|
||||
use crate::daemon::protocol::{ManagedForward, PaneProcs, PortProbe};
|
||||
use crate::ui::app::{
|
||||
CONTENT_INSET, TILE_GLYPH_SM, TILE_GLYPH_XS, TILE_SIZE_SM, TILE_SIZE_XS, Tty7App,
|
||||
tile_trailing_inset, tile_trailing_inset_sm,
|
||||
@@ -1149,9 +1149,12 @@ impl Tty7App {
|
||||
) -> Option<AnyElement> {
|
||||
let ctx = ctx?;
|
||||
let pane_id = ctx.pane_id;
|
||||
let ports = self
|
||||
// No answer yet for this pane defaults to a probe that is fine, not a
|
||||
// broken one: the panel has nothing to doubt until it has been told
|
||||
// something.
|
||||
let (ports, probe) = self
|
||||
.procs(Some(pane_id))
|
||||
.map(|p| p.ports.clone())
|
||||
.map(|p| (p.ports.clone(), p.probe.clone()))
|
||||
.unwrap_or_default();
|
||||
let forwards: Vec<ManagedForward> = self
|
||||
.loopback_panel
|
||||
@@ -1163,7 +1166,13 @@ impl Tty7App {
|
||||
let form_open = self.loopback_panel.form_pane_id == Some(pane_id);
|
||||
// A pane that cannot hold a forward and is serving nothing has no
|
||||
// section: the heading alone would be an empty promise.
|
||||
if ports.is_empty() && forwards.is_empty() && ctx.route.is_none() {
|
||||
//
|
||||
// Unless the reason it is serving nothing is that nobody managed to
|
||||
// look. Then the heading and one muted line under it are the only
|
||||
// place the panel can admit it does not know, and a silently absent
|
||||
// section is the bug (#731): someone whose server is plainly up reads
|
||||
// the missing section as tty7 saying there is no server.
|
||||
if ports.is_empty() && forwards.is_empty() && ctx.route.is_none() && probe.is_ok() {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -1324,22 +1333,26 @@ impl Tty7App {
|
||||
|this| {
|
||||
// "Nothing is listening" and "nobody could tell us"
|
||||
// look identical on screen unless the panel says which
|
||||
// one it means — and the second is a fixable thing:
|
||||
// the far end is running a server too old to answer.
|
||||
let (text, tone) = match self.right_panel.procs_unsupported {
|
||||
true => (
|
||||
t(L10nKey::PanelPortsUnsupported),
|
||||
cx.theme().muted_foreground,
|
||||
),
|
||||
false => (t(L10nKey::None), cx.theme().muted_foreground),
|
||||
// one it means — and every one of the second kind is a
|
||||
// fixable thing: a far end running a server too old to
|
||||
// answer, a probe that could not be run at all, a
|
||||
// server started under `sudo` whose sockets this user
|
||||
// is not allowed to see. Still one muted line in the
|
||||
// place the word "None" would have gone; the panel is
|
||||
// reporting what it knows, not raising an alarm.
|
||||
let key = match (self.right_panel.procs_unsupported, &probe) {
|
||||
(true, _) => L10nKey::PanelPortsUnsupported,
|
||||
(false, PortProbe::Unavailable(_)) => L10nKey::PanelPortsProbeFailed,
|
||||
(false, PortProbe::Restricted) => L10nKey::PanelPortsRestricted,
|
||||
(false, PortProbe::Ok) => L10nKey::None,
|
||||
};
|
||||
this.child(
|
||||
div()
|
||||
.px(px(CONTENT_INSET))
|
||||
.py(px(2.))
|
||||
.text_size(rems(TEXT))
|
||||
.text_color(tone)
|
||||
.child(text),
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(t(key)),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user