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
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:
@@ -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"
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod env;
|
||||
pub mod fs;
|
||||
pub mod id;
|
||||
pub mod panic_util;
|
||||
pub mod stdin_detect;
|
||||
pub mod util;
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user