Skip to content

Commit 790843e

Browse files
committed
fix(docker): parse all bot records before clearing orphan pids
- Restructure orphan reclamation into two phases: first parse all persisted records, then clear ownership and terminate orphans - Prevents data loss where a parse failure in a later record would leave earlier bots with wiped pids while their orphans remained alive, risking duplicate market makers on next restore - Add regression test verifying that good records retain their pid when a sibling record is corrupt
1 parent 38cdbbf commit 790843e

5 files changed

Lines changed: 103 additions & 23 deletions

File tree

.textile-monorepo-source

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
346ac212f158b5cccad79ed598256ecf9edb01d3
1+
57bc1216918740dc1872c69438da394f08f6364f

.textile-stitch-release-version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.1.138
1+
0.1.139

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.138"
3+
version = "0.1.139"
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: 99 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -174,10 +174,11 @@ impl ProcessRuntime {
174174
Ok(e) => e,
175175
Err(_) => return Ok(()),
176176
};
177-
// Collect first so orphan stops can run concurrently. Sequential waits
178-
// would be N × STOP_GRACE_SECS on a multi-bot restore.
177+
// Phase 1: parse every record before mutating any of them. If a later
178+
// file is corrupt, `?` must not leave earlier bots with their pid wiped
179+
// from disk while the orphan is still running (no ownership left to
180+
// reclaim on the next start → duplicate market makers).
179181
let mut pending: Vec<(String, LiveBot, bool)> = Vec::new();
180-
let mut orphans: Vec<(u32, Option<u64>)> = Vec::new();
181182
for entry in entries.filter_map(|e| e.ok()) {
182183
let path = entry.path();
183184
if path.extension().and_then(|e| e.to_str()) != Some("json") {
@@ -190,28 +191,32 @@ impl ProcessRuntime {
190191
let name = record.name.clone();
191192
let log_path = self.log_path_for(&name);
192193
let wanted = record.wanted_up && record.restart_unless_stopped;
193-
let mut live = LiveBot {
194-
record,
195-
child: None,
196-
log_path,
197-
restart_after: None,
198-
restart_failures: 0,
199-
};
200-
// Only signal the pid when it still looks like our stitch binary
201-
// (and, on Linux, the starttime matches) so a recycled PID is never
202-
// killed. Clear the persisted pid before waiting so a crash mid-
203-
// restore doesn't leave a stale claim.
194+
pending.push((
195+
name,
196+
LiveBot {
197+
record,
198+
child: None,
199+
log_path,
200+
restart_after: None,
201+
restart_failures: 0,
202+
},
203+
wanted,
204+
));
205+
}
206+
207+
// Phase 2: claim orphans (clear persisted pid) then stop them. Only
208+
// signal when the pid still looks like our stitch binary (and, on
209+
// Linux, the starttime matches) so a recycled PID is never killed.
210+
// Full STOP_GRACE_SECS so a mid-tick bot can finish after SIGTERM;
211+
// parallelize so fleet size doesn't multiply cold-start latency.
212+
let mut orphans: Vec<(u32, Option<u64>)> = Vec::new();
213+
for (_, live, _) in &mut pending {
204214
if let Some(pid) = live.record.pid.take() {
205215
let starttime = live.record.pid_starttime.take();
206216
orphans.push((pid, starttime));
207217
let _ = persist_record(&self.state_dir, &live.record);
208218
}
209-
pending.push((name, live, wanted));
210219
}
211-
212-
// Full STOP_GRACE_SECS so a mid-tick bot can finish after SIGTERM —
213-
// a 2s grace would SIGKILL during that window. Parallelize so fleet
214-
// size doesn't multiply cold-start latency.
215220
let stitch_bin = &self.stitch_bin;
216221
std::thread::scope(|scope| {
217222
for (pid, starttime) in orphans {
@@ -1513,6 +1518,81 @@ mod tests {
15131518
let _ = std::fs::remove_dir_all(root);
15141519
}
15151520

1521+
#[test]
1522+
fn load_persisted_keeps_pids_when_a_later_record_is_corrupt() {
1523+
// Regression: clearing pid from early records before a later parse
1524+
// failure left orphans running with no ownership on disk.
1525+
let root = temp_root("corrupt-record");
1526+
let bots = root.join("bots");
1527+
let state = bots.join(".process-runtime");
1528+
std::fs::create_dir_all(&state).unwrap();
1529+
let host = bots.join("bot-a");
1530+
std::fs::create_dir_all(&host).unwrap();
1531+
1532+
let stitch_bin = root.join("stitch");
1533+
std::fs::copy(which_sleep(), &stitch_bin).unwrap();
1534+
#[cfg(unix)]
1535+
{
1536+
use std::os::unix::fs::PermissionsExt;
1537+
let mut perms = std::fs::metadata(&stitch_bin).unwrap().permissions();
1538+
perms.set_mode(0o755);
1539+
std::fs::set_permissions(&stitch_bin, perms).unwrap();
1540+
}
1541+
1542+
let orphan = Command::new(&stitch_bin)
1543+
.arg("60")
1544+
.stdout(Stdio::null())
1545+
.stderr(Stdio::null())
1546+
.spawn()
1547+
.unwrap();
1548+
let orphan_pid = orphan.id();
1549+
let orphan_start = wait_process_starttime(orphan_pid);
1550+
std::mem::forget(orphan);
1551+
1552+
let good = PersistedBot {
1553+
id: "proc-stitch-bot-a".into(),
1554+
name: "stitch-bot-a".into(),
1555+
image: BUNDLED_IMAGE.into(),
1556+
labels: HashMap::new(),
1557+
env: vec![],
1558+
binds: vec![PersistedBind::from(&BindSpec::rw(&host, RUN_DIR))],
1559+
cmd: Some(vec!["60".into()]),
1560+
restart_unless_stopped: true,
1561+
wanted_up: true,
1562+
created_unix: now_unix(),
1563+
pid: Some(orphan_pid),
1564+
pid_starttime: orphan_start,
1565+
};
1566+
persist_record(&state, &good).unwrap();
1567+
// Lexically after stitch-bot-a.json so parse reaches the good record first.
1568+
std::fs::write(state.join("stitch-bot-z-corrupt.json"), "{not-json").unwrap();
1569+
1570+
match ProcessRuntime::new(stitch_bin, &bots) {
1571+
Ok(_) => panic!("expected parse failure from corrupt sibling record"),
1572+
Err(err) => assert!(
1573+
format!("{err:#}").contains("parsing"),
1574+
"expected parse failure, got: {err:#}"
1575+
),
1576+
}
1577+
1578+
let on_disk: PersistedBot = serde_json::from_str(
1579+
&std::fs::read_to_string(state.join("stitch-bot-a.json")).unwrap(),
1580+
)
1581+
.unwrap();
1582+
assert_eq!(
1583+
on_disk.pid,
1584+
Some(orphan_pid),
1585+
"good record pid must survive a later corrupt sibling"
1586+
);
1587+
assert_eq!(on_disk.pid_starttime, orphan_start);
1588+
assert!(
1589+
process_alive(orphan_pid),
1590+
"orphan must still be running so ownership can be reclaimed"
1591+
);
1592+
terminate_pid(orphan_pid, 2);
1593+
let _ = std::fs::remove_dir_all(root);
1594+
}
1595+
15161596
#[tokio::test]
15171597
async fn abandoned_one_shot_terminates_the_child() {
15181598
let sleep_bin = which_sleep();

0 commit comments

Comments
 (0)