Skip to content

Commit fa5d8a4

Browse files
author
lijiuyang.5137
committed
Preserve background processes after print completion
1 parent a708ef4 commit fa5d8a4

4 files changed

Lines changed: 107 additions & 1 deletion

File tree

src/pty.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,34 @@ impl PtyProcess {
8585
std::thread::sleep(Duration::from_millis(25));
8686
}
8787
}
88+
89+
pub fn finish(&mut self, timeout: Duration) {
90+
if self.pid <= 0 {
91+
return;
92+
}
93+
let pid = self.pid;
94+
unsafe {
95+
libc::kill(pid, libc::SIGTERM);
96+
}
97+
let started = Instant::now();
98+
loop {
99+
let mut status = 0;
100+
let result = unsafe { libc::waitpid(pid, &mut status, libc::WNOHANG) };
101+
if result == pid || result < 0 {
102+
self.pid = 0;
103+
return;
104+
}
105+
if started.elapsed() >= timeout {
106+
unsafe {
107+
libc::kill(pid, libc::SIGKILL);
108+
libc::waitpid(pid, &mut status, 0);
109+
}
110+
self.pid = 0;
111+
return;
112+
}
113+
std::thread::sleep(Duration::from_millis(25));
114+
}
115+
}
88116
}
89117

90118
impl Drop for PtyProcess {

src/runner.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ async fn run_print(invocation: Invocation) -> Result<i32> {
196196
}
197197
}
198198

199-
process.terminate(PTY_TERMINATE_TIMEOUT);
199+
process.finish(PTY_TERMINATE_TIMEOUT);
200200
if invocation.no_session_persistence {
201201
tail.remove_current_transcript()?;
202202
}

tests/cctty_cli.rs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,48 @@ fn stream_json_text_prompt_uses_tty_transcript() {
9191
assert_eq!(lines[3]["result"], "FAKE_RESPONSE: Say OK");
9292
}
9393

94+
#[test]
95+
#[cfg(unix)]
96+
fn normal_print_completion_preserves_hup_immune_background_process() {
97+
let fixture = FakeClaude::new();
98+
let workspace = tempfile::tempdir().unwrap();
99+
let config_dir = tempfile::tempdir().unwrap();
100+
let background_pid_path = workspace.path().join("background.pid");
101+
102+
let output = Command::cargo_bin("cctty")
103+
.unwrap()
104+
.env("CCTTY_CLAUDE_PATH", fixture.path())
105+
.env("CLAUDE_CONFIG_DIR", config_dir.path())
106+
.env("FAKE_CLAUDE_BACKGROUND_PID_PATH", &background_pid_path)
107+
.current_dir(workspace.path())
108+
.args([
109+
"--print",
110+
"--output-format",
111+
"stream-json",
112+
"START_BACKGROUND_SERVICE",
113+
])
114+
.output()
115+
.unwrap();
116+
117+
assert!(
118+
output.status.success(),
119+
"stderr:\n{}",
120+
String::from_utf8_lossy(&output.stderr)
121+
);
122+
let pid = std::fs::read_to_string(&background_pid_path)
123+
.unwrap()
124+
.trim()
125+
.parse::<u32>()
126+
.unwrap();
127+
let _guard = BackgroundPidGuard { pid };
128+
129+
std::thread::sleep(Duration::from_millis(250));
130+
assert!(
131+
process_exists(pid),
132+
"user-started background process {pid} should survive normal cctty completion"
133+
);
134+
}
135+
94136
#[test]
95137
fn stream_json_remote_control_active_screen_accepts_prompt() {
96138
let fixture = FakeClaude::new();
@@ -2641,3 +2683,28 @@ fn json_types(lines: &[Value]) -> Vec<&str> {
26412683
.map(|line| line["type"].as_str().unwrap())
26422684
.collect()
26432685
}
2686+
2687+
#[cfg(unix)]
2688+
struct BackgroundPidGuard {
2689+
pid: u32,
2690+
}
2691+
2692+
#[cfg(unix)]
2693+
impl Drop for BackgroundPidGuard {
2694+
fn drop(&mut self) {
2695+
unsafe {
2696+
libc::kill(self.pid as libc::pid_t, libc::SIGTERM);
2697+
}
2698+
std::thread::sleep(Duration::from_millis(50));
2699+
if process_exists(self.pid) {
2700+
unsafe {
2701+
libc::kill(self.pid as libc::pid_t, libc::SIGKILL);
2702+
}
2703+
}
2704+
}
2705+
}
2706+
2707+
#[cfg(unix)]
2708+
fn process_exists(pid: u32) -> bool {
2709+
unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
2710+
}

tests/support/mod.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,17 @@ while True:
245245
raw_prompt = buf[start + len(b"\x1b[200~"):end] if start >= 0 else buf[:end]
246246
prompt = raw_prompt.decode("utf-8", errors="replace")
247247
response = "FAKE_RESPONSE: " + prompt
248+
if "START_BACKGROUND_SERVICE" in prompt:
249+
pid_path = os.environ.get("FAKE_CLAUDE_BACKGROUND_PID_PATH")
250+
if not pid_path:
251+
raise RuntimeError("missing FAKE_CLAUDE_BACKGROUND_PID_PATH")
252+
child = subprocess.Popen(
253+
["sh", "-c", "trap '' HUP; exec sleep 600"],
254+
stdin=subprocess.DEVNULL,
255+
stdout=subprocess.DEVNULL,
256+
stderr=subprocess.DEVNULL,
257+
)
258+
Path(pid_path).write_text(str(child.pid), encoding="utf-8")
248259
if "USE_TTY_FIRST_FAKE_ASK_USER_QUESTION" in prompt:
249260
question_input = {
250261
"questions": [

0 commit comments

Comments
 (0)