Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 40 additions & 17 deletions crates/jcode-core/src/stdin_detect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ pub fn is_waiting_for_stdin(pid: u32) -> StdinState {
#[cfg(target_os = "linux")]
pub mod linux {
use super::*;
use std::collections::{HashMap, HashSet};

pub fn check(pid: u32) -> StdinState {
check_inner(pid, false)
Expand Down Expand Up @@ -93,37 +94,59 @@ pub mod linux {
.ok()
.map(|p| p.to_string_lossy().to_string());

// Check child processes
// Build a parent -> children graph from /proc so we can walk through
// chains like bash -> sh -> cat where only the grandchild reads
// stdin. Without this, single-layer wrappers were detected but nested
// wrappers fell through. See issue #86 / upstream PR #101.
let mut children_by_parent: HashMap<u32, Vec<u32>> = HashMap::new();
if let Ok(entries) = std::fs::read_dir("/proc") {
for entry in entries.flatten() {
if let Ok(name) = entry.file_name().into_string()
&& let Ok(child_pid) = name.parse::<u32>()
&& let Ok(proc_pid) = name.parse::<u32>()
&& let Ok(status) =
std::fs::read_to_string(format!("/proc/{}/status", child_pid))
std::fs::read_to_string(format!("/proc/{}/status", proc_pid))
{
for line in status.lines() {
if let Some(ppid_str) = line.strip_prefix("PPid:\t")
&& ppid_str.trim().parse::<u32>().ok() == Some(pid)
&& let Ok(ppid) = ppid_str.trim().parse::<u32>()
{
if let Some(ref parent_link) = parent_stdin_link {
let child_link =
std::fs::read_link(format!("/proc/{}/fd/0", child_pid))
.ok()
.map(|p| p.to_string_lossy().to_string());
if child_link.as_deref() != Some(parent_link) {
continue;
}
}
let child_result = check_inner(child_pid, true);
if child_result == StdinState::Reading {
return StdinState::Reading;
}
children_by_parent.entry(ppid).or_default().push(proc_pid);
break;
}
}
}
}
}

// DFS through descendants. Cycle-safe via `visited`. For each child we
// also gate on the parent stdin link match so we don't accidentally
// report unrelated descendants that happen to be reading their own
// stdin.
let mut stack = vec![pid];
let mut visited = HashSet::new();
while let Some(current) = stack.pop() {
if !visited.insert(current) {
continue;
}
if let Some(children) = children_by_parent.get(&current) {
for &child_pid in children {
stack.push(child_pid);
if let Some(ref parent_link) = parent_stdin_link {
let child_link = std::fs::read_link(format!("/proc/{}/fd/0", child_pid))
.ok()
.map(|p| p.to_string_lossy().to_string());
if child_link.as_deref() != Some(parent_link) {
continue;
}
}
let child_result = check_inner(child_pid, true);
if child_result == StdinState::Reading {
return StdinState::Reading;
}
}
}
}

result
}
}
Expand Down
29 changes: 29 additions & 0 deletions crates/jcode-core/src/stdin_detect_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,35 @@ fn test_child_process_tree_detection() {
);
}

#[cfg(target_os = "linux")]
#[test]
fn test_grandchild_process_tree_detection() {
// Two-layer wrapper: bash -> sh -> cat. Only the grandchild reads stdin.
// Regression for issue #86 / upstream PR #101 — single-layer detection
// worked but nested wrappers fell through.
let mut child = Command::new("bash")
.arg("-c")
.arg("sh -c 'cat'")
.stdin(Stdio::piped())
.stdout(Stdio::null())
.spawn()
.expect("failed to spawn nested shell");

let pid = child.id();
std::thread::sleep(std::time::Duration::from_millis(350));

let state = linux::check_process_tree(pid);

child.kill().ok();
child.wait().ok();

assert_eq!(
state,
StdinState::Reading,
"grandchild cat should be detected via recursive process tree walk"
);
}

#[cfg(target_os = "linux")]
#[test]
fn test_process_that_reads_then_exits() {
Expand Down