Skip to content

Commit da47aeb

Browse files
committed
fix(docker): trust starttime for orphan restore kills
- Use matching persisted starttime alone as pid-reuse guard on Linux instead of requiring `/proc/exe` reads, which can flake under load and cause restore to skip kills - Add short orphan grace period (2s) on restore separate from normal stop grace - Poll briefly after spawn to ensure stable starttime before persisting (execve races spawn) - Reap zombie children with WNOHANG waitpid after signals to prevent leftover `/proc` entries - Simplify `pid_is_our_stitch` to return early on starttime match when available, fall back to exe/cmdline checks only for unrecorded starttimes - Add regression test verifying starttime match alone is sufficient to identify and terminate persisted orphans
1 parent 865a39e commit da47aeb

5 files changed

Lines changed: 108 additions & 40 deletions

File tree

.textile-monorepo-source

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
f950320de4ed1797eaac61cbdd09f95cc573bbcf
1+
61409799eb0318a3e470357af80721005a4307a4

.textile-stitch-release-version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.1.136
1+
0.1.137

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.136"
3+
version = "0.1.137"
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: 104 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ const RESTART_BACKOFF_MAX: Duration = Duration::from_secs(60);
3939
/// Must not depend on UI/API polls — desktop `--autostart` has no browser traffic.
4040
const SUPERVISOR_TICK: Duration = Duration::from_secs(2);
4141

42+
/// Grace period when killing a persisted orphan on panel restore. Keep this short:
43+
/// the previous panel is already gone, and blocking startup for [`STOP_GRACE_SECS`]
44+
/// per orphan makes cold start feel broken. SIGKILL follows if needed.
45+
const ORPHAN_STOP_GRACE_SECS: i64 = 2;
46+
4247
#[derive(Debug, Clone, Serialize, Deserialize)]
4348
struct PersistedBot {
4449
id: String,
@@ -200,7 +205,7 @@ impl ProcessRuntime {
200205
// the starttime matches) so a recycled PID is never killed.
201206
if let Some(pid) = live.record.pid.take() {
202207
let starttime = live.record.pid_starttime.take();
203-
terminate_managed_pid(pid, starttime, &self.stitch_bin, STOP_GRACE_SECS);
208+
terminate_managed_pid(pid, starttime, &self.stitch_bin, ORPHAN_STOP_GRACE_SECS);
204209
// If graceful stop didn't land (stuck child, ignored SIGTERM),
205210
// don't leave the orphan running beside the respawn.
206211
if pid_is_our_stitch(pid, starttime, &self.stitch_bin) {
@@ -546,7 +551,8 @@ fn spawn_bot(stitch_bin: &Path, state_dir: &Path, live: &mut LiveBot) -> Result<
546551
.with_context(|| format!("spawning {} for {}", stitch_bin.display(), live.record.name))?;
547552
let pid = child.id();
548553
live.record.pid = Some(pid);
549-
live.record.pid_starttime = process_starttime(pid);
554+
// execve can race spawn(); retry so we persist a stable starttime.
555+
live.record.pid_starttime = wait_process_starttime(pid);
550556
live.child = Some(child);
551557
live.record.wanted_up = true;
552558
live.restart_after = None;
@@ -665,6 +671,21 @@ fn pid_kill0_exists(pid: u32) -> bool {
665671
std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
666672
}
667673

674+
/// Poll briefly for [`process_starttime`] after spawn — right after `execve` the
675+
/// `/proc` entry can briefly be missing under load.
676+
fn wait_process_starttime(pid: u32) -> Option<u64> {
677+
for _ in 0..50 {
678+
if let Some(start) = process_starttime(pid) {
679+
return Some(start);
680+
}
681+
if !process_alive(pid) {
682+
return None;
683+
}
684+
std::thread::sleep(Duration::from_millis(10));
685+
}
686+
process_starttime(pid)
687+
}
688+
668689
/// Linux starttime from `/proc/<pid>/stat` (field 22), used as a pid-reuse guard.
669690
fn process_starttime(pid: u32) -> Option<u64> {
670691
#[cfg(target_os = "linux")]
@@ -683,19 +704,22 @@ fn process_starttime(pid: u32) -> Option<u64> {
683704
}
684705
}
685706

686-
/// True when `pid` still looks like a stitch bot we spawned: exe/cmdline match
687-
/// `stitch_bin`, and on Linux the persisted starttime still matches.
707+
/// True when `pid` still looks like a stitch bot we spawned.
708+
///
709+
/// On Linux, a matching persisted `starttime` is sufficient: that pair is what
710+
/// we wrote at spawn, so it is the pid-reuse guard. Requiring exe/cmdline as
711+
/// well caused restore to skip kills under CI load when `/proc/<pid>/exe` was
712+
/// briefly unreadable, leaving the orphan running beside the respawn.
713+
/// Exe/cmdline checks apply when starttime was never recorded (non-Linux or
714+
/// older state files).
688715
fn pid_is_our_stitch(pid: u32, starttime: Option<u64>, stitch_bin: &Path) -> bool {
689716
if !process_alive(pid) {
690717
return false;
691718
}
692719
#[cfg(target_os = "linux")]
693720
{
694721
if let Some(expected) = starttime {
695-
match process_starttime(pid) {
696-
Some(actual) if actual == expected => {}
697-
_ => return false,
698-
}
722+
return matches!(process_starttime(pid), Some(actual) if actual == expected);
699723
}
700724
let want_name = stitch_bin.file_name();
701725
if let Ok(exe) = std::fs::read_link(format!("/proc/{pid}/exe")) {
@@ -784,40 +808,50 @@ fn terminate_managed_pid(pid: u32, starttime: Option<u64>, stitch_bin: &Path, gr
784808
}
785809

786810
fn terminate_pid(pid: u32, grace_secs: i64) {
787-
if !process_alive(pid) {
788-
return;
789-
}
790811
#[cfg(unix)]
791812
{
792813
let Ok(pid_i) = i32::try_from(pid) else {
793814
return;
794815
};
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);
816+
if process_alive(pid) {
817+
// libc::kill — same path as setup::terminate. Shelling out to kill(1)
818+
// can fail silently (PATH / wrapper) and leave orphans running.
819+
if grace_secs > 0 {
820+
// SAFETY: pid came from a process we spawned or persisted; signal is SIGTERM.
821+
unsafe {
822+
libc::kill(pid_i, libc::SIGTERM);
823+
}
824+
let deadline = Instant::now() + Duration::from_secs(grace_secs as u64);
825+
while Instant::now() < deadline {
826+
if !process_alive(pid) {
827+
break;
828+
}
829+
std::thread::sleep(Duration::from_millis(50));
830+
}
801831
}
802-
let deadline = Instant::now() + Duration::from_secs(grace_secs as u64);
803-
while Instant::now() < deadline {
804-
if !process_alive(pid) {
805-
return;
832+
if process_alive(pid) {
833+
// SAFETY: last-resort SIGKILL for a pid that survived SIGTERM (or grace 0).
834+
unsafe {
835+
libc::kill(pid_i, libc::SIGKILL);
836+
}
837+
let deadline = Instant::now() + Duration::from_secs(2);
838+
while Instant::now() < deadline && process_alive(pid) {
839+
std::thread::sleep(Duration::from_millis(50));
806840
}
807-
std::thread::sleep(Duration::from_millis(50));
808841
}
809842
}
810-
// SAFETY: last-resort SIGKILL for a pid that survived SIGTERM (or grace 0).
843+
// If this pid is still our child (e.g. test `mem::forget` orphans), reap
844+
// the zombie so later (pid, starttime) probes don't see a leftover entry.
845+
// SAFETY: WNOHANG waitpid on a concrete pid; ECHILD/ESRCH are ignored.
811846
unsafe {
812-
libc::kill(pid_i, libc::SIGKILL);
813-
}
814-
let deadline = Instant::now() + Duration::from_secs(2);
815-
while Instant::now() < deadline && process_alive(pid) {
816-
std::thread::sleep(Duration::from_millis(50));
847+
libc::waitpid(pid_i, std::ptr::null_mut(), libc::WNOHANG);
817848
}
818849
}
819850
#[cfg(windows)]
820851
{
852+
if !process_alive(pid) {
853+
return;
854+
}
821855
let _ = Command::new("taskkill")
822856
.args(["/PID", &pid.to_string(), "/T", "/F"])
823857
.stdout(Stdio::null())
@@ -1382,16 +1416,24 @@ mod tests {
13821416
.spawn()
13831417
.unwrap();
13841418
let orphan_pid = orphan.id();
1385-
let orphan_start = process_starttime(orphan_pid);
1419+
// Spawn returns before execve finishes; poll until /proc identity is stable.
1420+
let orphan_start = wait_process_starttime(orphan_pid);
1421+
let mut identity_ok = false;
1422+
for _ in 0..50 {
1423+
if pid_is_our_stitch(orphan_pid, orphan_start, &stitch_bin) {
1424+
identity_ok = true;
1425+
break;
1426+
}
1427+
std::thread::sleep(Duration::from_millis(10));
1428+
}
13861429
assert!(
13871430
process_alive(orphan_pid),
13881431
"precondition: orphan must be running"
13891432
);
1390-
// Identity must accept this orphan before we forget the Child — otherwise
1391-
// restore would skip the kill and the assertion below is meaningless.
13921433
assert!(
1393-
pid_is_our_stitch(orphan_pid, orphan_start, &stitch_bin),
1394-
"precondition: orphan must match stitch identity checks"
1434+
identity_ok,
1435+
"precondition: orphan must match stitch identity checks \
1436+
(pid={orphan_pid}, starttime={orphan_start:?})"
13951437
);
13961438
std::mem::forget(orphan);
13971439
let record = PersistedBot {
@@ -1642,14 +1684,40 @@ mod tests {
16421684
.spawn()
16431685
.unwrap();
16441686
let pid = orphan.id();
1645-
let start = process_starttime(pid);
16461687
std::mem::forget(orphan);
1647-
// Claim this sleep pid belongs to `true` — identity check must refuse.
1648-
terminate_managed_pid(pid, start, &true_bin, 2);
1688+
// No starttime + wrong binary name → must refuse (pid-reuse safe path).
1689+
terminate_managed_pid(pid, None, &true_bin, 2);
16491690
assert!(
16501691
process_alive(pid),
16511692
"must not kill a live process that isn't our stitch binary"
16521693
);
16531694
terminate_pid(pid, 2); // cleanup
16541695
}
1696+
1697+
#[test]
1698+
#[cfg(target_os = "linux")]
1699+
fn matching_starttime_is_enough_to_own_a_pid() {
1700+
// Regression: restore used to also require /proc/exe to match, and skipped
1701+
// the kill when that read flaked under parallel CI.
1702+
let sleep_bin = which_sleep();
1703+
let orphan = Command::new(&sleep_bin)
1704+
.arg("30")
1705+
.stdout(Stdio::null())
1706+
.stderr(Stdio::null())
1707+
.spawn()
1708+
.unwrap();
1709+
let pid = orphan.id();
1710+
let start = wait_process_starttime(pid);
1711+
std::mem::forget(orphan);
1712+
let unrelated = PathBuf::from("/nonexistent/stitch-not-this-path");
1713+
assert!(
1714+
start.is_some() && pid_is_our_stitch(pid, start, &unrelated),
1715+
"starttime match alone must identify the persisted orphan"
1716+
);
1717+
terminate_managed_pid(pid, start, &unrelated, 2);
1718+
assert!(
1719+
!process_alive(pid),
1720+
"matching starttime must be enough to terminate on restore"
1721+
);
1722+
}
16551723
}

0 commit comments

Comments
 (0)