chore: import upstream snapshot with attribution
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:10:34 +08:00
commit a789495a98
1551 changed files with 718128 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
[package]
name = "jcode-core"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
chrono = { version = "0.4", features = ["serde"] }
rand = "0.9.3"
libc = "0.2"
+35
View File
@@ -0,0 +1,35 @@
use std::ffi::OsStr;
/// Mutate the process environment for jcode runtime configuration.
///
/// Rust 2024 makes environment mutation unsafe because it can race with
/// concurrent environment access in foreign code. jcode intentionally mutates
/// process-local env vars to coordinate provider/runtime bootstrap before or
/// during task execution. We centralize that unsafety here so call sites remain
/// auditable.
pub fn set_var<K, V>(key: K, value: V)
where
K: AsRef<OsStr>,
V: AsRef<OsStr>,
{
// SAFETY: jcode treats these mutations as process-global configuration.
// They are a pre-existing design choice used throughout startup, auth,
// provider bootstrap, tests, and self-dev flows. Centralizing the unsafe
// operation here makes the Rust 2024 requirement explicit without
// scattering unsafe blocks across hundreds of call sites.
unsafe {
std::env::set_var(key, value);
}
}
/// Remove a process environment variable used by jcode runtime configuration.
pub fn remove_var<K>(key: K)
where
K: AsRef<OsStr>,
{
// SAFETY: see `set_var` above; this is the corresponding centralized
// removal operation for the same process-global configuration surface.
unsafe {
std::env::remove_var(key);
}
}
+33
View File
@@ -0,0 +1,33 @@
use std::path::Path;
/// Set file permissions to owner-only read/write (0o600).
/// No-op on Windows.
pub fn set_permissions_owner_only(path: &Path) -> std::io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
std::fs::set_permissions(path, perms)
}
#[cfg(windows)]
{
let _ = path;
Ok(())
}
}
/// Set directory permissions to owner-only read/write/execute (0o700).
/// No-op on Windows.
pub fn set_directory_permissions_owner_only(path: &Path) -> std::io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o700);
std::fs::set_permissions(path, perms)
}
#[cfg(windows)]
{
let _ = path;
Ok(())
}
}
+530
View File
@@ -0,0 +1,530 @@
use chrono::Utc;
use std::collections::HashSet;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicUsize, Ordering};
pub fn new_id(prefix: &str) -> String {
let ts = Utc::now().timestamp_millis();
let rand: u64 = rand::random();
format!("{}_{}_{}", prefix, ts, rand)
}
/// Server/location names with their icons.
///
/// Servers now use location nouns while sessions use client/entity nouns,
/// producing names like "harbor fox" or "observatory otter".
///
/// Icon constraints match `SESSION_NAMES`: single codepoints with default
/// emoji presentation (no VS16), see the comment there.
const SERVER_MODIFIERS: &[(&str, &str)] = &[
// Natural places
("cove", "🌊"),
("grove", "🌳"),
("meadow", "🌾"),
("marsh", "🌿"),
("lake", "🛶"),
("river", "🚣"),
("creek", "💧"),
("brook", "💧"),
("cliff", "🧗"),
("peak", "🗻"),
("summit", "🚠"),
("forest", "🌲"),
("garden", "🌷"),
("island", "🌴"),
("desert", "🌵"),
("beach", "🏄"),
// Built places
("harbor", ""),
("camp", ""),
("forge", "🔥"),
("citadel", "🏯"),
("station", "🚉"),
("observatory", "🔭"),
("workshop", "🔨"),
("lighthouse", "🗼"),
("temple", ""),
("castle", "🏰"),
("bridge", "🌉"),
("fountain", ""),
("stadium", "🎪"),
("factory", "🏭"),
("pagoda", "🛕"),
("hut", "🛖"),
];
/// Session/client names with their icons.
const SESSION_NAMES: &[(&str, &str)] = &[
// Animals, nature companions, and client entities. Every emoji here is a single, widely-supported
// codepoint (Unicode <= 12.0, no ZWJ sequences) with *default emoji
// presentation* (no VS16 / U+FE0F needed). Text-default codepoints that rely
// on VS16 render as monochrome outlines or tofu in macOS window titles
// (Ghostty/Terminal tab and titlebar fonts ignore the selector), so they are
// banned by `session_icons_render_as_single_safe_glyphs`.
("ant", "🐜"),
("bat", "🦇"),
("bird", "🐦"),
("bug", "🐛"),
("cat", "🐱"),
("chicken", "🐔"),
("chick", "🐥"),
("chipmunk", "🌰"),
("cow", "🐄"),
("crocodile", "🐊"),
("cricket", "🦗"),
("dog", "🐕"),
("dove", "🤍"),
("eagle", "🦅"),
("fish", "🐟"),
("fox", "🦊"),
("giraffe", "🦒"),
("hamster", "🐹"),
("ladybug", "🐞"),
("lobster", "🦞"),
("mosquito", "🦟"),
("owl", "🦉"),
("ox", "🐂"),
("pig", "🐷"),
("rat", "🐀"),
("ram", "🐏"),
("rooster", "🐓"),
("shrimp", "🦐"),
("sauropod", "🦕"),
("blowfish", "🐡"),
("buffalo", "🐃"),
("butterfly", "🦋"),
("badger", "🦡"),
("bear", "🐻"),
("crab", "🦀"),
("deer", "🦌"),
("duck", "🦆"),
("frog", "🐸"),
("goat", "🐐"),
("lion", "🦁"),
("wolf", "🐺"),
("horse", "🐴"),
("koala", "🐨"),
("llama", "🦙"),
("mouse", "🐭"),
("otter", "🦦"),
("panda", "🐼"),
("peacock", "🦚"),
("penguin", "🐧"),
("shark", "🦈"),
("sheep", "🐑"),
("sloth", "🦥"),
("snail", "🐌"),
("snake", "🐍"),
("spider", "🧶"),
("squid", "🦑"),
("swan", "🦢"),
("t-rex", "🦖"),
("tiger", "🐯"),
("turkey", "🦃"),
("whale", "🐋"),
("turtle", "🐢"),
("rabbit", "🐰"),
("parrot", "🦜"),
("jaguar", "🐆"),
("lizard", "🦎"),
("monkey", "🐒"),
("gorilla", "🦍"),
("orangutan", "🦧"),
("camel", "🐫"),
("elephant", "🐘"),
("rhino", "🦏"),
("hippo", "🦛"),
("boar", "🐗"),
("unicorn", "🦄"),
("kangaroo", "🦘"),
("hedgehog", "🦔"),
("skunk", "🦨"),
("raccoon", "🦝"),
("flamingo", "🦩"),
("dolphin", "🐬"),
("octopus", "🐙"),
("scorpion", "🦂"),
("zebra", "🦓"),
("stallion", "🐎"),
("dromedary", "🐪"),
("hog", "🐖"),
("kitten", "🐈"),
("poodle", "🐩"),
("hare", "🐇"),
("vole", "🐁"),
("dragon", "🐉"),
("humpback", "🐳"),
("guppy", "🐠"),
("nautilus", "🐚"),
("hatchling", "🐣"),
("wyvern", "🐲"),
("calf", "🐮"),
("macaque", "🐵"),
("tigress", "🐅"),
// Additional terminal-safe identities. These deliberately stay on Unicode
// 12 or older so they work in terminal tabs and window titles without a
// bundled emoji font. `bee` is intentionally absent: 🐝 is reserved for the
// global swarm marker rather than an individual client.
("puppy", "🐶"),
("duckling", "🐤"),
("mizaru", "🙈"),
("kikazaru", "🙉"),
("iwazaru", "🙊"),
("retriever", "🦮"),
("pawprint", "🐾"),
("piglet", "🐽"),
("bonehound", "🦴"),
("sabertooth", "🦷"),
("microbe", "🦠"),
("mushroom", "🍄"),
("cactus", "🌵"),
("clover", "🍀"),
("sunflower", "🌻"),
("hibiscus", "🌺"),
("blossom", "🌸"),
("daisy", "🌼"),
("tulip", "🌷"),
("rose", "🌹"),
("maple", "🍁"),
("seedling", "🌱"),
("evergreen", "🌲"),
("palmtree", "🌴"),
("herb", "🌿"),
];
fn session_name_cursor() -> &'static AtomicUsize {
static CURSOR: OnceLock<AtomicUsize> = OnceLock::new();
CURSOR.get_or_init(|| AtomicUsize::new((rand::random::<u64>() as usize) % SESSION_NAMES.len()))
}
/// Get an emoji icon for a session/client name word.
pub fn session_icon(name: &str) -> &'static str {
SESSION_NAMES
.iter()
.find(|(n, _)| *n == name)
.map(|(_, icon)| *icon)
.unwrap_or("💫")
}
/// Get an emoji icon for a server/location name word.
pub fn server_icon(name: &str) -> &'static str {
SERVER_MODIFIERS
.iter()
.find(|(n, _)| *n == name)
.map(|(_, icon)| *icon)
.unwrap_or("🔮")
}
/// Generate a memorable server name using a location noun.
/// Returns (full_id, short_name) where:
/// - full_id is the storage identifier like "server_blazing_1234567890_deadbeefcafebabe"
/// - short_name is the memorable part like "blazing"
pub fn new_memorable_server_id() -> (String, String) {
let ts = Utc::now().timestamp_millis();
let rand: u64 = rand::random();
// Use the random value to pick a location noun.
let idx = (rand as usize) % SERVER_MODIFIERS.len();
let (word, _) = SERVER_MODIFIERS[idx];
let short_name = word.to_string();
let full_id = format!("server_{}_{ts}_{rand:016x}", word);
(full_id, short_name)
}
/// Try to extract the memorable name from a server ID
/// e.g., "server_blazing_1234567890_deadbeefcafebabe" -> Some("blazing")
#[cfg(test)]
pub fn extract_server_name(server_id: &str) -> Option<&str> {
if let Some(rest) = server_id.strip_prefix("server_")
&& let Some(pos) = rest.find('_')
{
return Some(&rest[..pos]);
}
None
}
/// Generate a memorable session name
/// Returns (full_id, short_name) where:
/// - full_id is the storage identifier like "session_fox_1234567890_deadbeefcafebabe"
/// - short_name is the memorable part like "fox"
pub fn new_memorable_session_id() -> (String, String) {
new_memorable_session_id_avoiding(&HashSet::new())
}
/// Generate a memorable session identity that avoids names already held by
/// active sessions. A process-wide atomic cursor gives concurrent creators
/// distinct candidates, while `used_names` preserves uniqueness across server
/// reloads by excluding identities discovered from active-session markers.
///
/// When every portable identity is occupied, allocation gracefully wraps and
/// permits reuse rather than preventing session creation.
pub fn new_memorable_session_id_avoiding(used_names: &HashSet<String>) -> (String, String) {
let ts = Utc::now().timestamp_millis();
let rand: u64 = rand::random();
let cursor = session_name_cursor();
let word = (0..SESSION_NAMES.len())
.find_map(|_| {
let idx = cursor.fetch_add(1, Ordering::Relaxed) % SESSION_NAMES.len();
let (word, _) = SESSION_NAMES[idx];
(!used_names.contains(word)).then_some(word)
})
.unwrap_or_else(|| {
let idx = cursor.fetch_add(1, Ordering::Relaxed) % SESSION_NAMES.len();
SESSION_NAMES[idx].0
});
let short_name = word.to_string();
let full_id = format!("session_{}_{ts}_{rand:016x}", word);
(full_id, short_name)
}
/// Try to extract the memorable name from a session ID
/// e.g., "session_fox_1234567890_deadbeefcafebabe" -> Some("fox")
pub fn extract_session_name(session_id: &str) -> Option<&str> {
if let Some(rest) = session_id.strip_prefix("session_") {
// Session names are the first token after the prefix.
// This supports both old IDs (session_name_ts) and new IDs
// with an added random suffix (session_name_ts_rand).
if let Some(pos) = rest.find('_') {
return Some(&rest[..pos]);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new_memorable_session_id() {
let (full_id, short_name) = new_memorable_session_id();
// Full ID should start with "session_"
assert!(full_id.starts_with("session_"));
// Short name should be non-empty
assert!(!short_name.is_empty());
// Full ID should contain the short name
assert!(full_id.contains(&short_name));
// Short name should have a specific icon (not default)
let icon = session_icon(&short_name);
assert_ne!(
icon, "💫",
"Name '{}' should have a specific icon",
short_name
);
}
#[test]
fn test_extract_session_name() {
assert_eq!(extract_session_name("session_fox_1234567890"), Some("fox"));
assert_eq!(
extract_session_name("session_fox_1234567890_deadbeefcafebabe"),
Some("fox")
);
assert_eq!(
extract_session_name("session_blue-whale_1234567890"),
Some("blue-whale")
);
assert_eq!(
extract_session_name("session_blue-whale_1234567890_deadbeefcafebabe"),
Some("blue-whale")
);
assert_eq!(
extract_session_name("session_1234567890_9876543210"),
Some("1234567890")
);
assert_eq!(extract_session_name("invalid"), None);
assert_eq!(extract_session_name("session_"), None);
}
#[test]
fn test_unique_session_ids() {
let ids: std::collections::HashSet<String> =
(0..512).map(|_| new_memorable_session_id().0).collect();
assert_eq!(
ids.len(),
512,
"session IDs should stay unique in tight bursts"
);
}
#[test]
fn test_all_names_have_icons() {
for (name, expected_icon) in SESSION_NAMES {
let icon = session_icon(name);
assert_eq!(icon, *expected_icon, "Icon mismatch for '{}'", name);
assert_ne!(icon, "💫", "Name '{}' should have a specific icon", name);
}
}
#[test]
fn session_identity_pool_is_expanded_and_reserves_bee_for_swarm() {
assert_eq!(SESSION_NAMES.len(), 125);
assert!(
SESSION_NAMES
.iter()
.all(|(name, icon)| *name != "bee" && *icon != "🐝"),
"the bee identity must remain reserved for the global swarm marker"
);
assert_eq!(session_icon("bee"), "💫");
}
#[test]
fn avoiding_allocator_uses_every_available_identity_before_reuse() {
let mut used = HashSet::new();
for _ in 0..SESSION_NAMES.len() {
let (_, name) = new_memorable_session_id_avoiding(&used);
assert!(used.insert(name), "allocator reused an available identity");
}
assert_eq!(used.len(), SESSION_NAMES.len());
// Exhaustion must degrade to reuse rather than blocking session creation.
let (id, reused) = new_memorable_session_id_avoiding(&used);
assert!(id.starts_with(&format!("session_{reused}_")));
assert!(used.contains(&reused));
}
/// Returns true for emoji that commonly fail to render as a single glyph on
/// older terminal fonts or in window titles: ZWJ sequences (split into
/// pieces), codepoints added in Unicode 13.0 or later (rendered as tofu
/// boxes on fonts that predate them), and VS16 variation sequences
/// (text-default codepoints + U+FE0F, which macOS window/tab title fonts
/// render as monochrome outlines or tofu because the title renderer
/// ignores the emoji-presentation selector - the Ghostty-on-macOS bug).
/// We avoid a broad block range here because the Supplemental Symbols
/// block mixes safe Unicode 11/12 emoji (otter, sloth) with risky Unicode
/// 13+ ones (mammoth, beaver), so we list the unsafe codepoints
/// explicitly.
fn is_fragile_emoji(emoji: &str) -> bool {
// Unicode 13.0+ additions in the Supplemental Symbols block (U+1F900..U+1F9FF).
const UNSAFE_SUPPLEMENTAL: &[u32] = &[
0x1F9A3, // 🦣 mammoth (13.0)
0x1F9A4, // 🦤 dodo (13.0)
0x1F9AB, // 🦫 beaver (13.0)
0x1F9AC, // 🦬 bison (13.0)
0x1F9AD, // 🦭 seal (13.0)
];
emoji.chars().any(|c| {
let cp = c as u32;
c == '\u{200D}'
// VS16: emoji needing it are text-default and misrender in titles.
|| c == '\u{FE0F}'
// Symbols and Pictographs Extended-A (entirely Unicode 13+).
|| (0x1FA70..=0x1FAFF).contains(&cp)
|| UNSAFE_SUPPLEMENTAL.contains(&cp)
})
}
#[test]
fn session_icons_render_as_single_safe_glyphs() {
for (name, emoji) in SESSION_NAMES {
assert!(
!is_fragile_emoji(emoji),
"session name '{}' uses fragile emoji '{}' (ZWJ or Unicode 13+); \
pick a single widely-supported codepoint instead",
name,
emoji
);
}
}
#[test]
fn session_names_and_icons_are_unique() {
let mut names = std::collections::HashSet::new();
let mut icons = std::collections::HashSet::new();
for (name, emoji) in SESSION_NAMES {
assert!(names.insert(*name), "duplicate session name '{}'", name);
assert!(
icons.insert(*emoji),
"duplicate session icon '{}' (reused by '{}')",
emoji,
name
);
}
}
#[test]
fn server_icons_render_as_single_safe_glyphs() {
for (name, emoji) in SERVER_MODIFIERS {
assert!(
!is_fragile_emoji(emoji),
"server name '{}' uses fragile emoji '{}' (ZWJ or Unicode 13+); \
pick a single widely-supported codepoint instead",
name,
emoji
);
}
}
#[test]
fn test_new_memorable_server_id() {
let (full_id, short_name) = new_memorable_server_id();
// Full ID should start with "server_"
assert!(full_id.starts_with("server_"));
// Short name should be non-empty
assert!(!short_name.is_empty());
// Full ID should contain the short name
assert!(full_id.contains(&short_name));
// Short name should have a specific icon (not default)
let icon = server_icon(&short_name);
assert_ne!(
icon, "🔮",
"Modifier '{}' should have a specific icon",
short_name
);
}
#[test]
fn test_extract_server_name() {
assert_eq!(
extract_server_name("server_blazing_1234567890"),
Some("blazing")
);
assert_eq!(
extract_server_name("server_blazing_1234567890_deadbeefcafebabe"),
Some("blazing")
);
assert_eq!(
extract_server_name("server_rising_1234567890"),
Some("rising")
);
assert_eq!(extract_server_name("invalid"), None);
assert_eq!(extract_server_name("server_"), None);
}
#[test]
fn test_unique_server_ids() {
let ids: std::collections::HashSet<String> =
(0..256).map(|_| new_memorable_server_id().0).collect();
assert_eq!(
ids.len(),
256,
"server IDs should stay unique in tight bursts"
);
}
#[test]
fn test_all_modifiers_have_icons() {
for (name, expected_icon) in SERVER_MODIFIERS {
let icon = server_icon(name);
assert_eq!(icon, *expected_icon, "Icon mismatch for '{}'", name);
assert_ne!(
icon, "🔮",
"Modifier '{}' should have a specific icon",
name
);
}
}
}
+6
View File
@@ -0,0 +1,6 @@
pub mod env;
pub mod fs;
pub mod id;
pub mod panic_util;
pub mod stdin_detect;
pub mod util;
+28
View File
@@ -0,0 +1,28 @@
pub fn panic_payload_to_string(payload: &(dyn std::any::Any + Send)) -> String {
if let Some(s) = payload.downcast_ref::<&str>() {
(*s).to_string()
} else if let Some(s) = payload.downcast_ref::<String>() {
s.clone()
} else {
"unknown panic payload".to_string()
}
}
#[cfg(test)]
mod tests {
use super::panic_payload_to_string;
#[test]
fn panic_payload_to_string_handles_common_payloads() {
let str_payload: &(dyn std::any::Any + Send) = &"borrowed panic";
let string_payload: &(dyn std::any::Any + Send) = &String::from("owned panic");
let unknown_payload: &(dyn std::any::Any + Send) = &42usize;
assert_eq!(panic_payload_to_string(str_payload), "borrowed panic");
assert_eq!(panic_payload_to_string(string_payload), "owned panic");
assert_eq!(
panic_payload_to_string(unknown_payload),
"unknown panic payload"
);
}
}
+381
View File
@@ -0,0 +1,381 @@
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StdinState {
Reading,
NotReading,
Unknown,
}
pub fn is_waiting_for_stdin(pid: u32) -> StdinState {
#[cfg(target_os = "linux")]
return linux::check(pid);
#[cfg(target_os = "macos")]
return macos::check(pid);
#[cfg(target_os = "windows")]
return windows::check(pid);
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
return StdinState::Unknown;
}
#[cfg(target_os = "linux")]
pub mod linux {
use super::*;
pub fn check(pid: u32) -> StdinState {
check_inner(pid, false)
}
fn check_inner(pid: u32, strict: bool) -> StdinState {
// First try /proc/PID/syscall (most accurate - shows exact syscall + fd)
if let Ok(contents) = std::fs::read_to_string(format!("/proc/{}/syscall", pid)) {
// Format: "syscall_nr fd ..."
// read = 0 on x86_64, 63 on aarch64
// We want: read(0, ...) i.e. syscall read on fd 0 (stdin)
let parts: Vec<&str> = contents.split_whitespace().collect();
if parts.len() >= 2 {
let syscall_nr = parts[0];
let fd = parts[1];
// read syscall: 0 on x86_64, 63 on aarch64
let is_read = syscall_nr == "0" || syscall_nr == "63";
let is_stdin = fd == "0x0";
if is_read && is_stdin {
return StdinState::Reading;
}
}
}
// Fallback: /proc/PID/wchan (no special permissions needed).
// This is less exact than /proc/PID/syscall, so pair it with an fd 0
// pipe/pty check. For child processes, check_process_tree also verifies
// the child shares the parent's stdin pipe before calling strict mode.
if let Ok(wchan) = std::fs::read_to_string(format!("/proc/{}/wchan", pid)) {
let wchan = wchan.trim();
if (wchan == "n_tty_read"
|| wchan == "wait_woken"
|| wchan == "pipe_read"
|| wchan == "pipe_wait_readable"
|| wchan == "unix_stream_read_generic")
&& stdin_is_pipe_or_pty(pid)
{
return StdinState::Reading;
}
return StdinState::NotReading;
}
if strict {
StdinState::NotReading
} else {
StdinState::Unknown
}
}
fn stdin_is_pipe_or_pty(pid: u32) -> bool {
if let Ok(link) = std::fs::read_link(format!("/proc/{}/fd/0", pid)) {
let path = link.to_string_lossy();
return path.contains("pipe") || path.contains("pts") || path.contains("ptmx");
}
false
}
/// Check all threads in a process group (for cases where a child is the one reading)
pub fn check_process_tree(pid: u32) -> StdinState {
// Check the process itself
let result = check(pid);
if result == StdinState::Reading {
return result;
}
// Walk the descendant tree (children, grandchildren, ...) so wrapper
// chains like `sh -> wrapper -> reader` are detected even when the
// intermediate process is not itself reading stdin. At each hop we keep
// the existing same-stdin-pipe gate: a descendant only counts if it
// shares fd 0 with `pid`, so unrelated background processes that happen
// to read their own stdin do not trigger a false positive.
let parent_stdin_link = std::fs::read_link(format!("/proc/{}/fd/0", pid))
.ok()
.map(|p| p.to_string_lossy().to_string());
// Bound the traversal so a pathological/deep tree cannot turn this
// few-hundred-ms poll into an expensive scan, and guard against cycles.
const MAX_DEPTH: usize = 32;
const MAX_VISITED: usize = 512;
let mut visited: std::collections::HashSet<u32> = std::collections::HashSet::new();
visited.insert(pid);
// (pid, depth) work queue for a breadth-first descent.
let mut queue: std::collections::VecDeque<(u32, usize)> = direct_children(pid)
.into_iter()
.map(|child| (child, 1usize))
.collect();
while let Some((child_pid, depth)) = queue.pop_front() {
if !visited.insert(child_pid) || visited.len() > MAX_VISITED {
continue;
}
// Only descendants sharing the same stdin pipe are relevant.
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;
}
}
if check_inner(child_pid, true) == StdinState::Reading {
return StdinState::Reading;
}
if depth < MAX_DEPTH {
for grandchild in direct_children(child_pid) {
queue.push_back((grandchild, depth + 1));
}
}
}
result
}
/// Return the direct child PIDs of `pid` using the kernel's
/// `/proc/<pid>/task/<tid>/children` interface, which lists only the
/// immediate children of each thread as a space-separated list. This avoids
/// scanning the entire `/proc` directory and reading every process's
/// `status` file just to filter on `PPid`.
///
/// Requires `CONFIG_PROC_CHILDREN` (standard on modern Linux). If the file
/// is unavailable we fall back to a `/proc` scan so behavior is preserved on
/// older kernels.
pub(crate) fn direct_children(pid: u32) -> Vec<u32> {
// A process's children are tracked per-thread, so union across all
// threads of `pid`.
let mut children = Vec::new();
// Distinguish "interface works and the process has no children" (the
// common leaf-process case) from "interface unavailable". Falling back
// to the full /proc scan on a merely-empty result reintroduced the
// exact per-poll whole-/proc scan this function exists to avoid
// (issue #392 A1): every childless polled process triggered a scan of
// every PID's status file on each 500ms stdin poll.
let mut children_interface_readable = false;
if let Ok(threads) = std::fs::read_dir(format!("/proc/{}/task", pid)) {
for thread in threads.flatten() {
let tid = thread.file_name();
let Some(tid) = tid.to_str() else { continue };
if let Ok(list) =
std::fs::read_to_string(format!("/proc/{}/task/{}/children", pid, tid))
{
children_interface_readable = true;
for child in list.split_whitespace() {
if let Ok(child_pid) = child.parse::<u32>() {
children.push(child_pid);
}
}
}
}
}
if children_interface_readable {
return children;
}
// Fallback for kernels without CONFIG_PROC_CHILDREN: scan /proc once.
// This preserves the original behavior on those systems.
children_via_proc_scan(pid)
}
fn children_via_proc_scan(pid: u32) -> Vec<u32> {
let mut children = Vec::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(status) =
std::fs::read_to_string(format!("/proc/{}/status", child_pid))
{
for line in status.lines() {
if let Some(ppid_str) = line.strip_prefix("PPid:\t")
&& ppid_str.trim().parse::<u32>().ok() == Some(pid)
{
children.push(child_pid);
break;
}
}
}
}
}
children
}
}
#[cfg(target_os = "macos")]
mod macos {
use super::*;
use std::mem;
// libproc bindings
unsafe extern "C" {
fn proc_pidinfo(
pid: i32,
flavor: i32,
arg: u64,
buffer: *mut libc::c_void,
buffersize: i32,
) -> i32;
fn proc_pidfdinfo(
pid: i32,
fd: i32,
flavor: i32,
buffer: *mut libc::c_void,
buffersize: i32,
) -> i32;
}
const PROC_PIDLISTFDS: i32 = 1;
const PROC_PIDFDVNODEPATHINFO: i32 = 2;
const PROC_PIDFDSOCKETINFO: i32 = 3;
const PROC_PIDFDPIPEINFO: i32 = 6;
#[repr(C)]
struct proc_fdinfo {
proc_fd: i32,
proc_fdtype: u32,
}
// Thread info
const PROC_PIDTHREADINFO: i32 = 5;
const PROC_PIDLISTTHREADS: i32 = 6;
#[repr(C)]
struct proc_threadinfo {
pth_user_time: u64,
pth_system_time: u64,
pth_cpu_usage: i32,
pth_policy: i32,
pth_run_state: i32,
pth_flags: i32,
pth_sleep_time: i32,
pth_curpri: i32,
pth_priority: i32,
pth_maxpriority: i32,
pth_name: [u8; 64],
}
const TH_STATE_WAITING: i32 = 2;
pub fn check(pid: u32) -> StdinState {
// Check if fd 0 (stdin) is a pipe or pty
if !stdin_is_interactive(pid as i32) {
return StdinState::NotReading;
}
// Check thread states - if any thread is in WAITING state,
// the process might be blocked on I/O
if is_thread_waiting(pid as i32) {
return StdinState::Reading;
}
StdinState::NotReading
}
fn stdin_is_interactive(pid: i32) -> bool {
// Get list of file descriptors
let fd_size = mem::size_of::<proc_fdinfo>() as i32;
let buf_size = fd_size * 256; // up to 256 fds
let mut buf = vec![0u8; buf_size as usize];
let ret = unsafe {
proc_pidinfo(
pid,
PROC_PIDLISTFDS,
0,
buf.as_mut_ptr() as *mut libc::c_void,
buf_size,
)
};
if ret <= 0 {
return false;
}
let num_fds = ret / fd_size;
let fds = unsafe {
std::slice::from_raw_parts(buf.as_ptr() as *const proc_fdinfo, num_fds as usize)
};
// Check if fd 0 exists and is a pipe or vnode (pty)
for fd in fds {
if fd.proc_fd == 0 {
// fd type 1 = vnode (could be pty), 6 = pipe
return fd.proc_fdtype == 1 || fd.proc_fdtype == 6;
}
}
false
}
fn is_thread_waiting(pid: i32) -> bool {
// Get thread list
let mut thread_ids = vec![0u64; 64];
let ret = unsafe {
proc_pidinfo(
pid,
PROC_PIDLISTTHREADS,
0,
thread_ids.as_mut_ptr() as *mut libc::c_void,
(thread_ids.len() * mem::size_of::<u64>()) as i32,
)
};
if ret <= 0 {
return false;
}
let num_threads = ret as usize / mem::size_of::<u64>();
// Check each thread's state
for i in 0..num_threads {
let mut tinfo: proc_threadinfo = unsafe { mem::zeroed() };
let ret = unsafe {
proc_pidinfo(
pid,
PROC_PIDTHREADINFO,
thread_ids[i],
&mut tinfo as *mut _ as *mut libc::c_void,
mem::size_of::<proc_threadinfo>() as i32,
)
};
if ret > 0 && tinfo.pth_run_state == TH_STATE_WAITING {
return true;
}
}
false
}
}
#[cfg(target_os = "windows")]
mod windows {
use super::*;
pub fn check(_pid: u32) -> StdinState {
// Windows: use NtQueryInformationThread to check thread state
// A process blocked on ReadFile/ReadConsole on stdin will have
// its thread in a Wait state with a wait reason of UserRequest
//
// For now, use the simpler approach: check if the process has
// a console handle and its thread is in a wait state via
// WaitForSingleObject with zero timeout on the process handle
// TODO: implement with windows-sys crate
// - OpenProcess(PROCESS_QUERY_INFORMATION, pid)
// - NtQuerySystemInformation for thread states
// - Check for KWAIT_REASON::WrUserRequest on stdin handle
StdinState::Unknown
}
}
#[cfg(test)]
#[path = "stdin_detect_tests.rs"]
mod stdin_detect_tests;
+321
View File
@@ -0,0 +1,321 @@
use super::*;
use std::process::{Command, Stdio};
#[test]
fn test_own_process_not_reading_stdin() {
let pid = std::process::id();
let state = is_waiting_for_stdin(pid);
assert_ne!(state, StdinState::Reading);
}
#[test]
fn test_nonexistent_pid() {
let state = is_waiting_for_stdin(u32::MAX);
assert_ne!(state, StdinState::Reading);
}
#[cfg(target_os = "linux")]
#[test]
fn test_blocked_process_detected() {
let mut child = Command::new("cat")
.stdin(Stdio::piped())
.stdout(Stdio::null())
.spawn()
.expect("failed to spawn cat");
let pid = child.id();
std::thread::sleep(std::time::Duration::from_millis(200));
let state = linux::check_process_tree(pid);
child.kill().ok();
child.wait().ok();
assert_eq!(
state,
StdinState::Reading,
"cat should be waiting for stdin"
);
}
#[cfg(target_os = "linux")]
#[test]
fn test_running_process_not_reading() {
let mut child = Command::new("sleep")
.arg("10")
.stdin(Stdio::null())
.stdout(Stdio::null())
.spawn()
.expect("failed to spawn sleep");
let pid = child.id();
std::thread::sleep(std::time::Duration::from_millis(100));
let state = linux::check(pid);
child.kill().ok();
child.wait().ok();
assert_eq!(
state,
StdinState::NotReading,
"sleep should not be reading stdin"
);
}
#[cfg(target_os = "linux")]
#[test]
fn test_child_process_tree_detection() {
// bash -c "cat" spawns bash which spawns cat - cat is the one reading stdin
let mut child = Command::new("bash")
.arg("-c")
.arg("cat")
.stdin(Stdio::piped())
.stdout(Stdio::null())
.spawn()
.expect("failed to spawn bash");
let pid = child.id();
std::thread::sleep(std::time::Duration::from_millis(300));
// The bash process itself may not be reading, but its child (cat) should be
let state = linux::check_process_tree(pid);
child.kill().ok();
child.wait().ok();
assert_eq!(
state,
StdinState::Reading,
"child cat should be detected via process tree"
);
}
#[cfg(target_os = "linux")]
#[test]
fn test_grandchild_process_tree_detection() {
// Wrapper chain: an outer bash spawns an inner `bash -c cat`, so the actual
// stdin reader (`cat`) is a GRANDCHILD of the tracked pid. The intermediate
// bash is not itself reading stdin, so detection requires recursing past
// direct children (issue #373). A trailing `; true` keeps each bash from
// exec-optimizing itself away so the nesting (outer bash -> inner bash ->
// cat) is preserved.
let mut child = Command::new("bash")
.arg("-c")
.arg("bash -c 'cat; true'; true")
.stdin(Stdio::piped())
.stdout(Stdio::null())
.spawn()
.expect("failed to spawn bash");
let pid = child.id();
std::thread::sleep(std::time::Duration::from_millis(400));
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_direct_children_lists_immediate_children() {
// Spawn a parent shell that itself spawns a long-lived child (`sleep`).
// `direct_children` should report the immediate child PID(s) without
// scanning all of /proc.
// Use a compound command so bash does NOT exec-optimize itself away and
// actually stays alive as the parent of a `sleep` child.
let mut child = Command::new("bash")
.arg("-c")
.arg("sleep 5; true")
.stdin(Stdio::null())
.stdout(Stdio::null())
.spawn()
.expect("failed to spawn bash");
let pid = child.id();
std::thread::sleep(std::time::Duration::from_millis(200));
let kids = linux::direct_children(pid);
// Verify parentage BEFORE killing the parent, otherwise the child
// reparents to init (ppid 1) and the check races.
let mut all_parented_by_pid = !kids.is_empty();
for kid in &kids {
let status = std::fs::read_to_string(format!("/proc/{}/status", kid)).unwrap_or_default();
let ppid = status
.lines()
.find_map(|l| l.strip_prefix("PPid:\t"))
.and_then(|v| v.trim().parse::<u32>().ok());
if ppid != Some(pid) {
all_parented_by_pid = false;
}
}
child.kill().ok();
child.wait().ok();
assert!(
!kids.is_empty(),
"bash should have at least one direct child (the sleep)"
);
assert!(
all_parented_by_pid,
"every reported child should be parented by {pid}; got {kids:?}"
);
}
#[cfg(target_os = "linux")]
#[test]
fn test_process_that_reads_then_exits() {
use std::io::Write;
let mut child = Command::new("head")
.arg("-n1")
.stdin(Stdio::piped())
.stdout(Stdio::null())
.spawn()
.expect("failed to spawn head");
let pid = child.id();
std::thread::sleep(std::time::Duration::from_millis(200));
// Should be reading initially
let state_before = linux::check(pid);
// Write a line - head should read it and exit
if let Some(ref mut stdin) = child.stdin {
stdin.write_all(b"hello\n").ok();
stdin.flush().ok();
}
// Wait for exit
let status = child.wait().expect("failed to wait");
// After exit, checking the pid should not report Reading
let state_after = is_waiting_for_stdin(pid);
assert_eq!(
state_before,
StdinState::Reading,
"head should be reading before input"
);
assert_ne!(
state_after,
StdinState::Reading,
"head should not be reading after exit"
);
assert!(status.success(), "head should exit successfully");
}
#[cfg(target_os = "linux")]
#[test]
fn test_process_with_closed_stdin_not_reading() {
// Spawn a process with stdin completely closed (null)
let mut child = Command::new("cat")
.stdin(Stdio::null())
.stdout(Stdio::null())
.spawn()
.expect("failed to spawn cat");
let pid = child.id();
// cat with /dev/null as stdin should read EOF immediately and exit
std::thread::sleep(std::time::Duration::from_millis(200));
let state = is_waiting_for_stdin(pid);
child.kill().ok();
child.wait().ok();
// cat with /dev/null gets EOF immediately, should not be stuck reading
assert_ne!(state, StdinState::Reading);
}
#[cfg(target_os = "linux")]
#[test]
fn test_multiple_sequential_reads() {
use std::io::Write;
// Use a program that reads multiple lines
let mut child = Command::new("head")
.arg("-n2")
.stdin(Stdio::piped())
.stdout(Stdio::null())
.spawn()
.expect("failed to spawn head");
let pid = child.id();
std::thread::sleep(std::time::Duration::from_millis(200));
// Should be reading first line
let state1 = linux::check(pid);
assert_eq!(
state1,
StdinState::Reading,
"should be waiting for first line"
);
// Send first line
if let Some(ref mut stdin) = child.stdin {
stdin.write_all(b"line1\n").ok();
stdin.flush().ok();
}
std::thread::sleep(std::time::Duration::from_millis(100));
// Should be reading second line
let state2 = linux::check(pid);
assert_eq!(
state2,
StdinState::Reading,
"should be waiting for second line"
);
// Send second line
if let Some(ref mut stdin) = child.stdin {
stdin.write_all(b"line2\n").ok();
stdin.flush().ok();
}
let status = child.wait().expect("failed to wait");
assert!(status.success());
}
#[cfg(target_os = "linux")]
#[test]
fn direct_children_of_childless_process_does_not_scan_proc() {
// Regression test for issue #392 A1 (second occurrence): a childless
// process must return an empty list via /proc/<pid>/task/<tid>/children
// without falling back to the whole-/proc scan. We can't observe syscalls
// here, but we can assert the interface itself reports readable-and-empty
// for a leaf process we control, which is the branch condition the fix
// keys on.
let mut child = std::process::Command::new("sleep")
.arg("5")
.spawn()
.expect("spawn sleep");
let pid = child.id();
// `sleep` spawns no children. The children interface must be readable so
// direct_children() returns empty WITHOUT the proc-scan fallback.
let path = format!("/proc/{}/task/{}/children", pid, pid);
let readable = std::fs::read_to_string(&path).is_ok();
let children = super::linux::direct_children(pid);
let _ = child.kill();
let _ = child.wait();
assert!(
readable,
"kernel lacks CONFIG_PROC_CHILDREN; fallback scan is expected on this system"
);
assert!(
children.is_empty(),
"sleep should have no children, got {children:?}"
);
}
+248
View File
@@ -0,0 +1,248 @@
/// Truncate a string at a valid UTF-8 character boundary.
///
/// Returns a slice of at most `max_bytes` bytes, ending at a valid char boundary.
/// This prevents panics when truncating strings that contain multi-byte characters.
pub fn truncate_str(s: &str, max_bytes: usize) -> &str {
if s.len() <= max_bytes {
return s;
}
// Find the largest valid char boundary at or before max_bytes
let mut end = max_bytes;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
&s[..end]
}
pub const APPROX_CHARS_PER_TOKEN: usize = 4;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApproxTokenSeverity {
Normal,
Warning,
Danger,
}
/// Estimate token count using jcode's existing chars-per-token heuristic.
pub fn estimate_tokens(s: &str) -> usize {
s.len() / APPROX_CHARS_PER_TOKEN
}
/// Format a number with ASCII thousands separators.
pub fn format_number(n: usize) -> String {
let digits = n.to_string();
let mut out = String::with_capacity(digits.len() + digits.len() / 3);
for (idx, ch) in digits.chars().enumerate() {
if idx > 0 && (digits.len() - idx).is_multiple_of(3) {
out.push(',');
}
out.push(ch);
}
out
}
/// Format a token count in the compact style used by the TUI.
pub fn format_approx_token_count(tokens: usize) -> String {
match tokens {
0..=999 => format!("{} tok", tokens),
1_000..=9_999 => {
let whole = tokens / 1_000;
let tenth = (tokens % 1_000) / 100;
if tenth == 0 {
format!("{}k tok", whole)
} else {
format!("{}.{}k tok", whole, tenth)
}
}
_ => format!("{}k tok", tokens / 1_000),
}
}
/// Light severity levels for tool outputs that are unusually large for context.
pub fn approx_tool_output_token_severity(tokens: usize) -> ApproxTokenSeverity {
if tokens >= 12_000 {
ApproxTokenSeverity::Danger
} else if tokens >= 4_000 {
ApproxTokenSeverity::Warning
} else {
ApproxTokenSeverity::Normal
}
}
/// Extract the payload from an SSE `data:` line.
///
/// The SSE spec allows an optional single space after the colon, so both
/// `data:{...}` and `data: {...}` are valid and should parse identically.
pub fn sse_data_line(line: &str) -> Option<&str> {
line.strip_prefix("data:")
.map(|rest| rest.strip_prefix(' ').unwrap_or(rest))
}
#[cfg(unix)]
fn read_max_open_files_limits() -> Option<(String, String)> {
let contents = std::fs::read_to_string("/proc/self/limits").ok()?;
contents.lines().find_map(|line| {
let parts: Vec<_> = line.split_whitespace().collect();
(parts.len() >= 5 && parts[0] == "Max" && parts[1] == "open" && parts[2] == "files")
.then(|| (parts[3].to_string(), parts[4].to_string()))
})
}
/// Summarize the current process's file-descriptor usage for debugging reload or
/// connect failures such as EMFILE/`Too many open files`.
pub fn process_fd_diagnostic_snapshot() -> String {
#[cfg(unix)]
{
let pid = std::process::id();
let fd_dir = std::path::Path::new("/proc/self/fd");
let mut total = 0usize;
let mut sockets = 0usize;
let mut pipes = 0usize;
let mut anon = 0usize;
let mut chars = 0usize;
let mut regs = 0usize;
let mut dirs = 0usize;
let mut other = 0usize;
if let Ok(entries) = std::fs::read_dir(fd_dir) {
for entry in entries.flatten() {
total += 1;
let target = std::fs::read_link(entry.path())
.ok()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
if target.starts_with("socket:") {
sockets += 1;
} else if target.starts_with("pipe:") {
pipes += 1;
} else if target.starts_with("anon_inode:") {
anon += 1;
} else if target.starts_with("/dev/") {
chars += 1;
} else if target.starts_with('/') {
match std::fs::metadata(&target) {
Ok(meta) if meta.is_file() => regs += 1,
Ok(meta) if meta.is_dir() => dirs += 1,
Ok(_) | Err(_) => other += 1,
}
} else {
other += 1;
}
}
}
let (soft_limit, hard_limit) = read_max_open_files_limits()
.unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string()));
format!(
"pid={} fds={} soft_limit={} hard_limit={} kinds={{socket:{}, pipe:{}, anon_inode:{}, char:{}, file:{}, dir:{}, other:{}}}",
pid, total, soft_limit, hard_limit, sockets, pipes, anon, chars, regs, dirs, other
)
}
#[cfg(not(unix))]
{
format!(
"pid={} fd snapshot unsupported on this platform",
std::process::id()
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_truncate_ascii() {
assert_eq!(truncate_str("hello", 10), "hello");
assert_eq!(truncate_str("hello world", 5), "hello");
}
#[test]
fn test_truncate_multibyte() {
// "学" is 3 bytes (E5 AD A6)
let s = "abc学def";
assert_eq!(truncate_str(s, 3), "abc"); // exactly before 学
assert_eq!(truncate_str(s, 4), "abc"); // mid-char, back up
assert_eq!(truncate_str(s, 5), "abc"); // mid-char, back up
assert_eq!(truncate_str(s, 6), "abc学"); // exactly after 学
}
#[test]
fn test_truncate_emoji() {
// "🦀" is 4 bytes
let s = "hi🦀bye";
assert_eq!(truncate_str(s, 2), "hi");
assert_eq!(truncate_str(s, 3), "hi"); // mid-emoji
assert_eq!(truncate_str(s, 5), "hi"); // mid-emoji
assert_eq!(truncate_str(s, 6), "hi🦀");
}
#[test]
fn test_truncate_empty() {
assert_eq!(truncate_str("", 10), "");
assert_eq!(truncate_str("hello", 0), "");
}
#[test]
fn test_truncate_boundary_inside_multibyte_does_not_panic() {
// Regression for issue #398: a multibyte char ('改', 3 bytes) straddling
// the byte-200 boundary used to panic with a raw `&s[..200]` slice.
// 199 ASCII bytes + '改' places the char at bytes 199..202.
let s = format!("{}", "a".repeat(199));
let truncated = truncate_str(&s, 200);
// Backs up to the previous char boundary (byte 199), never panics.
assert_eq!(truncated.len(), 199);
assert!(s.starts_with(truncated));
}
#[test]
fn test_sse_data_line_accepts_optional_space() {
assert_eq!(sse_data_line("data: {\"ok\":true}"), Some("{\"ok\":true}"));
assert_eq!(sse_data_line("data:{\"ok\":true}"), Some("{\"ok\":true}"));
assert_eq!(sse_data_line("event: message"), None);
}
#[test]
fn test_format_number_adds_commas() {
assert_eq!(format_number(0), "0");
assert_eq!(format_number(12), "12");
assert_eq!(format_number(1_234), "1,234");
assert_eq!(format_number(12_345_678), "12,345,678");
}
#[test]
fn test_format_approx_token_count_compacts_thousands() {
assert_eq!(format_approx_token_count(999), "999 tok");
assert_eq!(format_approx_token_count(1_000), "1k tok");
assert_eq!(format_approx_token_count(1_900), "1.9k tok");
assert_eq!(format_approx_token_count(10_000), "10k tok");
}
#[test]
fn test_process_fd_diagnostic_snapshot_mentions_pid() {
let snapshot = process_fd_diagnostic_snapshot();
assert!(snapshot.contains("pid="));
}
#[test]
fn test_approx_tool_output_token_severity_thresholds() {
assert_eq!(
approx_tool_output_token_severity(3_999),
ApproxTokenSeverity::Normal
);
assert_eq!(
approx_tool_output_token_severity(4_000),
ApproxTokenSeverity::Warning
);
assert_eq!(
approx_tool_output_token_severity(11_999),
ApproxTokenSeverity::Warning
);
assert_eq!(
approx_tool_output_token_severity(12_000),
ApproxTokenSeverity::Danger
);
}
}