Skip to content

Commit 6646881

Browse files
committed
fix(docker): stop skipping orphan kills on flaky /proc reads
- Change `process_alive` to distinguish between NotFound (dead) and transient /proc read errors (treat as alive via kill(0) probe) - Use `libc::kill` directly instead of shelling out to kill(1), avoiding silent failures that leave orphans running - Add graceful-stop timeout for SIGTERM before falling back to SIGKILL - Send SIGKILL to orphans that survive graceful stop during restore, preventing them from running alongside the respawn - Make process-runtime temp directories unique within the same second by adding a monotonic sequence counter to avoid test fixture collisions - Improve test diagnostics when orphan cleanup fails by including /proc status and exe path in panic message
1 parent 4f578c8 commit 6646881

5 files changed

Lines changed: 97 additions & 49 deletions

File tree

.textile-monorepo-source

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
27f445cfdc792bc60ad88ca2df9eed36b86a597a
1+
decdf61d60e98beebd2aa5844bae0b27e483936f

.textile-stitch-release-version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.1.134
1+
0.1.135

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "stitch-bot"
3-
version = "0.1.134"
3+
version = "0.1.135"
44
edition = "2021"
55
description = "Stitch — Textile filler-network operator bot; market-makes the filler order book with signed UniswapX limit orders."
66
license = "AGPL-3.0-or-later"

src/panel/docker/process_api.rs

Lines changed: 93 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,15 @@ impl ProcessRuntime {
201201
if let Some(pid) = live.record.pid.take() {
202202
let starttime = live.record.pid_starttime.take();
203203
terminate_managed_pid(pid, starttime, &self.stitch_bin, STOP_GRACE_SECS);
204+
// If graceful stop didn't land (stuck child, ignored SIGTERM),
205+
// don't leave the orphan running beside the respawn.
206+
if pid_is_our_stitch(pid, starttime, &self.stitch_bin) {
207+
tracing::warn!(
208+
pid,
209+
"orphan survived graceful stop on restore; sending SIGKILL"
210+
);
211+
terminate_pid(pid, 0);
212+
}
204213
let _ = persist_record(&self.state_dir, &live.record);
205214
}
206215
if wanted {
@@ -592,39 +601,41 @@ fn stop_child(child: &mut Child, grace_secs: i64) -> Result<()> {
592601
}
593602
}
594603

595-
/// True when `pid` is a live (non-zombie) process. Zombies still have a
596-
/// `/proc/<pid>` entry and answer `kill(pid, 0)`, but they aren't running
597-
/// market-maker code — safe to ignore and respawn.
604+
/// True when `pid` still exists as a non-zombie process.
605+
///
606+
/// Zombies keep a `/proc/<pid>` entry and answer `kill(pid, 0)`, but they aren't
607+
/// running market-maker code — safe to ignore and respawn.
608+
///
609+
/// Important: a transient failure to read `/proc/<pid>/status` must NOT be
610+
/// treated as "dead". That skips terminate and leaves orphans running; under
611+
/// parallel CI load the later assertion then sees the original starttime still
612+
/// alive.
598613
fn process_alive(pid: u32) -> bool {
599614
if pid == 0 {
600615
return false;
601616
}
602617
#[cfg(target_os = "linux")]
603618
{
604-
let Ok(status) = std::fs::read_to_string(format!("/proc/{pid}/status")) else {
605-
return false;
606-
};
607-
for line in status.lines() {
608-
let Some(state) = line.strip_prefix("State:") else {
609-
continue;
610-
};
611-
// `State:\tZ (zombie)` — anything else (R/S/D/T…) counts as alive.
612-
return !state.trim_start().starts_with('Z');
619+
match std::fs::read_to_string(format!("/proc/{pid}/status")) {
620+
Ok(status) => {
621+
for line in status.lines() {
622+
let Some(state) = line.strip_prefix("State:") else {
623+
continue;
624+
};
625+
// `State:\tZ (zombie)` — anything else (R/S/D/T…) counts as alive.
626+
return !state.trim_start().starts_with('Z');
627+
}
628+
}
629+
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return false,
630+
Err(_) => {
631+
// Fall through to the kill(0) probe.
632+
}
613633
}
614-
// status file without State: treat as alive if the pid exists.
615-
true
634+
pid_kill0_exists(pid)
616635
}
617636
#[cfg(all(unix, not(target_os = "linux")))]
618637
{
619-
let Ok(pid) = i32::try_from(pid) else {
620-
return false;
621-
};
622-
// SAFETY: kill(pid, 0) is a liveness probe.
623-
let rc = unsafe { libc::kill(pid, 0) };
624-
if rc == 0 {
625-
return true;
626-
}
627-
std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
638+
pid_kill0_exists(pid)
628639
}
629640
#[cfg(windows)]
630641
{
@@ -639,6 +650,21 @@ fn process_alive(pid: u32) -> bool {
639650
}
640651
}
641652

653+
/// `kill(pid, 0)` existence probe. True when the pid exists (including zombies)
654+
/// or we lack permission to signal it.
655+
#[cfg(unix)]
656+
fn pid_kill0_exists(pid: u32) -> bool {
657+
let Ok(pid) = i32::try_from(pid) else {
658+
return false;
659+
};
660+
// SAFETY: kill(pid, 0) is a liveness probe; it does not deliver a signal.
661+
let rc = unsafe { libc::kill(pid, 0) };
662+
if rc == 0 {
663+
return true;
664+
}
665+
std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
666+
}
667+
642668
/// Linux starttime from `/proc/<pid>/stat` (field 22), used as a pid-reuse guard.
643669
fn process_starttime(pid: u32) -> Option<u64> {
644670
#[cfg(target_os = "linux")]
@@ -763,24 +789,28 @@ fn terminate_pid(pid: u32, grace_secs: i64) {
763789
}
764790
#[cfg(unix)]
765791
{
766-
// kill(1) rather than libc::kill — clearer and matches what operators run.
767-
let _ = Command::new("kill")
768-
.args(["-TERM", &pid.to_string()])
769-
.stdout(Stdio::null())
770-
.stderr(Stdio::null())
771-
.status();
772-
let deadline = Instant::now() + Duration::from_secs(grace_secs.max(0) as u64);
773-
while Instant::now() < deadline {
774-
if !process_alive(pid) {
775-
return;
792+
let Ok(pid_i) = i32::try_from(pid) else {
793+
return;
794+
};
795+
// libc::kill — same path as setup::terminate. Shelling out to kill(1)
796+
// can fail silently (PATH / wrapper) and leave orphans running.
797+
if grace_secs > 0 {
798+
// SAFETY: pid came from a process we spawned or persisted; signal is SIGTERM.
799+
unsafe {
800+
libc::kill(pid_i, libc::SIGTERM);
801+
}
802+
let deadline = Instant::now() + Duration::from_secs(grace_secs as u64);
803+
while Instant::now() < deadline {
804+
if !process_alive(pid) {
805+
return;
806+
}
807+
std::thread::sleep(Duration::from_millis(50));
776808
}
777-
std::thread::sleep(Duration::from_millis(100));
778809
}
779-
let _ = Command::new("kill")
780-
.args(["-KILL", &pid.to_string()])
781-
.stdout(Stdio::null())
782-
.stderr(Stdio::null())
783-
.status();
810+
// SAFETY: last-resort SIGKILL for a pid that survived SIGTERM (or grace 0).
811+
unsafe {
812+
libc::kill(pid_i, libc::SIGKILL);
813+
}
784814
let deadline = Instant::now() + Duration::from_secs(2);
785815
while Instant::now() < deadline && process_alive(pid) {
786816
std::thread::sleep(Duration::from_millis(50));
@@ -1357,6 +1387,12 @@ mod tests {
13571387
process_alive(orphan_pid),
13581388
"precondition: orphan must be running"
13591389
);
1390+
// Identity must accept this orphan before we forget the Child — otherwise
1391+
// restore would skip the kill and the assertion below is meaningless.
1392+
assert!(
1393+
pid_is_our_stitch(orphan_pid, orphan_start, &stitch_bin),
1394+
"precondition: orphan must match stitch identity checks"
1395+
);
13601396
std::mem::forget(orphan);
13611397
let record = PersistedBot {
13621398
id: "proc-stitch-bot-a".into(),
@@ -1374,7 +1410,7 @@ mod tests {
13741410
};
13751411
persist_record(&state, &record).unwrap();
13761412

1377-
let rt = ProcessRuntime::new(stitch_bin, &bots).unwrap();
1413+
let rt = ProcessRuntime::new(stitch_bin.clone(), &bots).unwrap();
13781414
let new_pid = {
13791415
let inner = rt.inner.lock().unwrap();
13801416
inner["stitch-bot-a"].record.pid
@@ -1397,10 +1433,18 @@ mod tests {
13971433
}
13981434
std::thread::sleep(Duration::from_millis(50));
13991435
}
1400-
assert!(
1401-
orphan_gone,
1402-
"orphan pid {orphan_pid} still running with original starttime after restore"
1403-
);
1436+
if !orphan_gone {
1437+
let status = std::fs::read_to_string(format!("/proc/{orphan_pid}/status"))
1438+
.unwrap_or_else(|e| format!("<status unreadable: {e}>"));
1439+
let exe = std::fs::read_link(format!("/proc/{orphan_pid}/exe"))
1440+
.map(|p| p.display().to_string())
1441+
.unwrap_or_else(|e| format!("<exe unreadable: {e}>"));
1442+
panic!(
1443+
"orphan pid {orphan_pid} still running with original starttime after restore \
1444+
(new_pid={new_pid:?}, exe={exe}, still_ours={still_ours}, status=\n{status})",
1445+
still_ours = pid_is_our_stitch(orphan_pid, orphan_start, &stitch_bin),
1446+
);
1447+
}
14041448
drop(rt);
14051449
let _ = std::fs::remove_dir_all(root);
14061450
}
@@ -1539,8 +1583,12 @@ mod tests {
15391583
}
15401584

15411585
fn temp_root(tag: &str) -> PathBuf {
1586+
// Include a monotonic seq: now_unix() is 1s resolution and all tests share
1587+
// one pid, so two fixtures in the same second must not collide / wipe each other.
1588+
static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1589+
let seq = SEQ.fetch_add(1, Ordering::Relaxed);
15421590
let root = std::env::temp_dir().join(format!(
1543-
"stitch-process-rt-{tag}-{}-{}",
1591+
"stitch-process-rt-{tag}-{}-{}-{seq}",
15441592
std::process::id(),
15451593
now_unix()
15461594
));

0 commit comments

Comments
 (0)