返回 CodeWhale
exec_persistent_service.rs
根目录 / crates / tui / tests / integration / exec_persistent_service.rs
1 //! Real-process acceptance for `persist:true` background services on the
2 //! headless `codewhale exec` host.
3 //!
4 //! Three black-box contracts against the actual binary and real child
5 //! processes, with a `wiremock` OpenAI-compatible provider:
6 //!
7 //! - a successful exec releases the explicitly persisted service: the exec
8 //! process exits 0, emits a `service_released` receipt, and the service
9 //! process is still alive afterwards;
10 //! - a failed exec (incomplete non-limit stop) kills the pending service and
11 //! exits nonzero;
12 //! - a terminating signal mid-turn kills the pending service and exits
13 //! nonzero.
14
15 #![cfg(unix)]
16
17 use std::io::Read;
18 use std::path::Path;
19 use std::process::{Command, Stdio};
20 use std::sync::atomic::{AtomicUsize, Ordering};
21 use std::sync::{Arc, Mutex, OnceLock, mpsc};
22 use std::time::{Duration, Instant};
23
24 async fn serialize_persistent_service_tests() -> tokio::sync::MutexGuard<'static, ()> {
25 static LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
26 LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
27 .lock()
28 .await
29 }
30
31 use serde_json::{Value, json};
32 use tempfile::TempDir;
33 use wait_timeout::ChildExt;
34 use wiremock::matchers::{method, path};
35 use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
36
37 const TEST_MODEL: &str = "persist-service-model";
38 const RUN_TIMEOUT: Duration = Duration::from_secs(120);
39
40 fn sse_chunk(value: Value) -> String {
41 format!(
42 "data: {}\n\n",
43 serde_json::to_string(&value).expect("SSE JSON")
44 )
45 }
46
47 /// First model turn: one Bash tool call staging the persistent service.
48 fn stage_service_sse(command: &str) -> String {
49 let arguments = serde_json::to_string(&json!({
50 "command": command,
51 "background": true,
52 "persist": true,
53 }))
54 .expect("tool arguments JSON");
55 [
56 sse_chunk(json!({
57 "id": "chatcmpl-stage",
58 "object": "chat.completion.chunk",
59 "model": TEST_MODEL,
60 "choices": [{"index": 0, "delta": {"tool_calls": [{"index": 0, "id": "call_persist", "type": "function", "function": {"name": "Bash", "arguments": arguments}}]}, "finish_reason": null}]
61 })),
62 sse_chunk(json!({
63 "id": "chatcmpl-stage",
64 "object": "chat.completion.chunk",
65 "model": TEST_MODEL,
66 "choices": [{"index": 0, "delta": {}, "finish_reason": "tool_calls"}],
67 "usage": {"prompt_tokens": 12, "completion_tokens": 4, "total_tokens": 16}
68 })),
69 "data: [DONE]\n\n".to_string(),
70 ]
71 .join("")
72 }
73
74 /// Second model turn: an ordinary completed final answer.
75 fn final_answer_sse() -> String {
76 [
77 sse_chunk(json!({
78 "id": "chatcmpl-final",
79 "object": "chat.completion.chunk",
80 "model": TEST_MODEL,
81 "choices": [{"index": 0, "delta": {"content": "service is up"}, "finish_reason": null}]
82 })),
83 sse_chunk(json!({
84 "id": "chatcmpl-final",
85 "object": "chat.completion.chunk",
86 "model": TEST_MODEL,
87 "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
88 "usage": {"prompt_tokens": 30, "completion_tokens": 3, "total_tokens": 33}
89 })),
90 "data: [DONE]\n\n".to_string(),
91 ]
92 .join("")
93 }
94
95 /// Second model turn: incomplete non-limit stop. `length` now degrades and
96 /// continues the headless loop (no default max-turns), so this fixture uses
97 /// `content_filter` to force a failed exec and prove pending services die.
98 fn incomplete_answer_sse() -> String {
99 [
100 sse_chunk(json!({
101 "id": "chatcmpl-incomplete",
102 "object": "chat.completion.chunk",
103 "model": TEST_MODEL,
104 "choices": [{"index": 0, "delta": {"content": "partial"}, "finish_reason": null}]
105 })),
106 sse_chunk(json!({
107 "id": "chatcmpl-incomplete",
108 "object": "chat.completion.chunk",
109 "model": TEST_MODEL,
110 "choices": [{"index": 0, "delta": {}, "finish_reason": "content_filter"}],
111 "usage": {"prompt_tokens": 30, "completion_tokens": 2, "total_tokens": 32}
112 })),
113 "data: [DONE]\n\n".to_string(),
114 ]
115 .join("")
116 }
117
118 fn sse_response(body: String) -> ResponseTemplate {
119 ResponseTemplate::new(200)
120 .insert_header("content-type", "text/event-stream")
121 .insert_header("cache-control", "no-cache")
122 .set_body_string(body)
123 }
124
125 fn json_response(value: Value) -> ResponseTemplate {
126 ResponseTemplate::new(200)
127 .insert_header("content-type", "application/json")
128 .set_body_json(value)
129 }
130
131 /// Sequential provider: first POST stages the service; later POSTs get the
132 /// scenario's second turn. The failure scenario waits for explicit service
133 /// readiness; an optional delay holds the exec mid-turn for the signal case.
134 struct SequentialTurns {
135 requests: Arc<AtomicUsize>,
136 stage_command: String,
137 second_turn: String,
138 second_turn_delay: Option<Duration>,
139 second_turn_ready: Option<Mutex<mpsc::Receiver<()>>>,
140 }
141
142 impl Respond for SequentialTurns {
143 fn respond(&self, _request: &Request) -> ResponseTemplate {
144 let call = self.requests.fetch_add(1, Ordering::SeqCst);
145 if call == 0 {
146 sse_response(stage_service_sse(&self.stage_command))
147 } else {
148 if call == 1
149 && let Some(ready) = &self.second_turn_ready
150 && ready
151 .lock()
152 .expect("service readiness receiver")
153 .recv_timeout(RUN_TIMEOUT)
154 .is_err()
155 {
156 return ResponseTemplate::new(504)
157 .set_body_string("test did not confirm service readiness");
158 }
159 let response = sse_response(self.second_turn.clone());
160 match self.second_turn_delay {
161 Some(delay) => response.set_delay(delay),
162 None => response,
163 }
164 }
165 }
166 }
167
168 async fn start_mock_llm(
169 stage_command: &str,
170 second_turn: String,
171 second_turn_delay: Option<Duration>,
172 second_turn_ready: Option<mpsc::Receiver<()>>,
173 ) -> MockServer {
174 let server = MockServer::start().await;
175
176 Mock::given(method("GET"))
177 .and(path("/v1/models"))
178 .respond_with(json_response(json!({
179 "object": "list",
180 "data": [{ "id": TEST_MODEL, "object": "model" }]
181 })))
182 .mount(&server)
183 .await;
184
185 Mock::given(method("POST"))
186 .and(path("/v1/chat/completions"))
187 .respond_with(SequentialTurns {
188 requests: Arc::new(AtomicUsize::new(0)),
189 stage_command: stage_command.to_string(),
190 second_turn,
191 second_turn_delay,
192 second_turn_ready: second_turn_ready.map(Mutex::new),
193 })
194 .mount(&server)
195 .await;
196
197 server
198 }
199
200 fn preserve_host_env(command: &mut Command) {
201 command.env_clear();
202 for key in [
203 "PATH",
204 "SHELL",
205 "TEMP",
206 "TMP",
207 "TERM",
208 "COLORTERM",
209 "LANG",
210 "LC_ALL",
211 ] {
212 if let Some(value) = std::env::var_os(key) {
213 command.env(key, value);
214 }
215 }
216 }
217
218 fn exec_command(server: &MockServer, workspace: &Path, home: &Path) -> Command {
219 let mut command = Command::new(crate::binary::codewhale());
220 preserve_host_env(&mut command);
221 command
222 .current_dir(workspace)
223 .arg("--workspace")
224 .arg(workspace)
225 .arg("--no-project-config")
226 .arg("exec")
227 .arg("--auto")
228 .arg("--sandbox")
229 .arg("danger-full-access")
230 .arg("--model")
231 .arg(TEST_MODEL)
232 .arg("--output-format")
233 .arg("stream-json")
234 .arg("start the service, then confirm")
235 .env("HOME", home)
236 .env("USERPROFILE", home)
237 .env("XDG_CONFIG_HOME", home.join(".config"))
238 .env("XDG_DATA_HOME", home.join(".local").join("share"))
239 .env("XDG_CACHE_HOME", home.join(".cache"))
240 .env(
241 "CODEWHALE_CONFIG_PATH",
242 home.join(".codewhale").join("config.toml"),
243 )
244 .env(
245 "DEEPSEEK_CONFIG_PATH",
246 home.join(".deepseek").join("config.toml"),
247 )
248 .env("DEEPSEEK_API_KEY", "ci-test-key-not-real")
249 .env("DEEPSEEK_BASE_URL", server.uri())
250 .env("CODEWHALE_BASE_URL", server.uri())
251 .env("DEEPSEEK_MODEL", TEST_MODEL)
252 .env("CODEWHALE_MODEL", TEST_MODEL)
253 .env("RUST_LOG", "warn")
254 .stdout(Stdio::piped())
255 .stderr(Stdio::piped());
256 std::fs::create_dir_all(home.join(".codewhale")).expect("create codewhale config dir");
257 std::fs::create_dir_all(home.join(".deepseek")).expect("create deepseek config dir");
258 command
259 }
260
261 fn read_pipe_in_background<R>(mut reader: R) -> std::thread::JoinHandle<std::io::Result<Vec<u8>>>
262 where
263 R: Read + Send + 'static,
264 {
265 std::thread::spawn(move || {
266 let mut bytes = Vec::new();
267 reader.read_to_end(&mut bytes)?;
268 Ok(bytes)
269 })
270 }
271
272 fn join_pipe(handle: std::thread::JoinHandle<std::io::Result<Vec<u8>>>, label: &str) -> String {
273 let bytes = handle
274 .join()
275 .unwrap_or_else(|_| panic!("{label} reader thread panicked"))
276 .unwrap_or_else(|error| panic!("{label} read failed: {error}"));
277 String::from_utf8_lossy(&bytes).into_owned()
278 }
279
280 fn stream_events(stdout: &str) -> Vec<Value> {
281 stdout
282 .lines()
283 .filter_map(|line| serde_json::from_str::<Value>(line.trim()).ok())
284 .collect()
285 }
286
287 fn pid_is_alive(pid: i32) -> bool {
288 // SAFETY: signal 0 performs only an existence/permission check.
289 unsafe { libc::kill(pid, 0) == 0 }
290 }
291
292 fn kill_process_group(pid: i32) {
293 // SAFETY: the pid came from this test's own staged service; the negative
294 // pid targets its process group only.
295 unsafe {
296 libc::kill(-pid, libc::SIGKILL);
297 }
298 }
299
300 fn wait_for_pid_file(path: &Path) -> Result<i32, String> {
301 let deadline = Instant::now() + RUN_TIMEOUT;
302 loop {
303 if let Ok(contents) = std::fs::read_to_string(path)
304 && let Ok(pid) = contents.trim().parse::<i32>()
305 {
306 return Ok(pid);
307 }
308 if Instant::now() >= deadline {
309 return Err(format!(
310 "service pid file never appeared at {}",
311 path.display()
312 ));
313 }
314 std::thread::sleep(Duration::from_millis(50));
315 }
316 }
317
318 fn wait_for_pid_death(pid: i32) {
319 let deadline = Instant::now() + Duration::from_secs(15);
320 while pid_is_alive(pid) {
321 assert!(
322 Instant::now() < deadline,
323 "pending persistent service (pid {pid}) must be killed"
324 );
325 std::thread::sleep(Duration::from_millis(50));
326 }
327 }
328
329 /// The staged service records its own pid, then stays alive.
330 const SERVICE_COMMAND: &str = "echo $$ > service.pid; exec sleep 600";
331
332 #[tokio::test(flavor = "multi_thread")]
333 async fn successful_exec_releases_persisted_service() {
334 let _serial = serialize_persistent_service_tests().await;
335 let server = start_mock_llm(SERVICE_COMMAND, final_answer_sse(), None, None).await;
336 let workspace = TempDir::new().expect("workspace tempdir");
337 let home = TempDir::new().expect("home tempdir");
338
339 let mut child = exec_command(&server, workspace.path(), home.path())
340 .spawn()
341 .expect("spawn codewhale exec");
342 let stdout_reader = read_pipe_in_background(child.stdout.take().expect("stdout pipe"));
343 let stderr_reader = read_pipe_in_background(child.stderr.take().expect("stderr pipe"));
344 let status = child
345 .wait_timeout(RUN_TIMEOUT)
346 .expect("wait for exec")
347 .unwrap_or_else(|| {
348 let _ = child.kill();
349 let _ = child.wait();
350 panic!("exec timed out");
351 });
352 let stdout = join_pipe(stdout_reader, "stdout");
353 let stderr = join_pipe(stderr_reader, "stderr");
354
355 let service_pid = wait_for_pid_file(&workspace.path().join("service.pid"))
356 .unwrap_or_else(|error| panic!("{error}\nstdout:\n{stdout}\nstderr:\n{stderr}"));
357 let events = stream_events(&stdout);
358 let released = events
359 .iter()
360 .find(|event| event.get("type").and_then(Value::as_str) == Some("service_released"))
361 .unwrap_or_else(|| {
362 panic!("missing service_released event\nstdout:\n{stdout}\nstderr:\n{stderr}")
363 });
364
365 assert!(
366 status.success(),
367 "successful exec must exit 0 (got {status:?})\nstderr:\n{stderr}"
368 );
369 assert_eq!(
370 released.get("pid").and_then(Value::as_u64),
371 Some(u64::try_from(service_pid).expect("pid fits u64")),
372 "release receipt must carry the real service pid"
373 );
374 assert_eq!(
375 released.get("ownership").and_then(Value::as_str),
376 Some("external")
377 );
378 assert!(
379 pid_is_alive(service_pid),
380 "explicitly persisted service must survive successful headless exit"
381 );
382
383 kill_process_group(service_pid);
384 }
385
386 #[tokio::test(flavor = "multi_thread")]
387 async fn failed_exec_kills_pending_service_and_exits_nonzero() {
388 let _serial = serialize_persistent_service_tests().await;
389 let (service_ready, service_ready_receiver) = mpsc::channel();
390 let server = start_mock_llm(
391 SERVICE_COMMAND,
392 incomplete_answer_sse(),
393 None,
394 Some(service_ready_receiver),
395 )
396 .await;
397 let workspace = TempDir::new().expect("workspace tempdir");
398 let home = TempDir::new().expect("home tempdir");
399
400 let mut child = exec_command(&server, workspace.path(), home.path())
401 .spawn()
402 .expect("spawn codewhale exec");
403 let stdout_reader = read_pipe_in_background(child.stdout.take().expect("stdout pipe"));
404 let stderr_reader = read_pipe_in_background(child.stderr.take().expect("stderr pipe"));
405
406 // Spawning a background process does not mean its first instruction ran.
407 // Hold the deliberate model failure until the real service is ready, so
408 // cancellation cannot kill it before it writes the PID we need to check.
409 let service_pid = match wait_for_pid_file(&workspace.path().join("service.pid")) {
410 Ok(pid) => pid,
411 Err(error) => {
412 drop(service_ready);
413 // Let the host clean up its managed services before forcing exit.
414 // SAFETY: direct child of this test.
415 unsafe {
416 libc::kill(
417 i32::try_from(child.id()).expect("child pid fits i32"),
418 libc::SIGTERM,
419 );
420 }
421 if child
422 .wait_timeout(Duration::from_secs(5))
423 .ok()
424 .flatten()
425 .is_none()
426 {
427 let _ = child.kill();
428 let _ = child.wait();
429 }
430 let stdout = join_pipe(stdout_reader, "stdout");
431 let stderr = join_pipe(stderr_reader, "stderr");
432 panic!("{error}\nstdout:\n{stdout}\nstderr:\n{stderr}");
433 }
434 };
435 assert!(
436 pid_is_alive(service_pid),
437 "service must be alive before the model fails"
438 );
439 service_ready
440 .send(())
441 .expect("release the deliberate model failure");
442
443 let status = match child.wait_timeout(RUN_TIMEOUT).expect("wait for exec") {
444 Some(status) => status,
445 None => {
446 let _ = child.kill();
447 let _ = child.wait();
448 let stdout = join_pipe(stdout_reader, "stdout");
449 let stderr = join_pipe(stderr_reader, "stderr");
450 panic!("exec timed out\nstdout:\n{stdout}\nstderr:\n{stderr}");
451 }
452 };
453 let stdout = join_pipe(stdout_reader, "stdout");
454 let stderr = join_pipe(stderr_reader, "stderr");
455 assert!(
456 !status.success(),
457 "provider incomplete stop must fail the exec\nstdout:\n{stdout}\nstderr:\n{stderr}"
458 );
459 assert!(
460 !stream_events(&stdout)
461 .iter()
462 .any(|event| event.get("type").and_then(Value::as_str) == Some("service_released")),
463 "a failed exec must never release a pending service"
464 );
465 wait_for_pid_death(service_pid);
466 }
467
468 #[tokio::test(flavor = "multi_thread")]
469 async fn terminating_signal_kills_pending_service_and_exits_nonzero() {
470 let _serial = serialize_persistent_service_tests().await;
471 // Hold the second model turn open long past the signal.
472 let server = start_mock_llm(
473 SERVICE_COMMAND,
474 final_answer_sse(),
475 Some(Duration::from_secs(300)),
476 None,
477 )
478 .await;
479 let workspace = TempDir::new().expect("workspace tempdir");
480 let home = TempDir::new().expect("home tempdir");
481
482 let mut child = exec_command(&server, workspace.path(), home.path())
483 .spawn()
484 .expect("spawn codewhale exec");
485 let stdout_reader = read_pipe_in_background(child.stdout.take().expect("stdout pipe"));
486 let stderr_reader = read_pipe_in_background(child.stderr.take().expect("stderr pipe"));
487
488 // The pid file proves the service was staged before the signal.
489 let service_pid = wait_for_pid_file(&workspace.path().join("service.pid"))
490 .unwrap_or_else(|error| panic!("{error}"));
491 assert!(pid_is_alive(service_pid));
492
493 // SAFETY: direct child of this test.
494 unsafe {
495 libc::kill(
496 i32::try_from(child.id()).expect("child pid fits i32"),
497 libc::SIGTERM,
498 );
499 }
500 let status = child
501 .wait_timeout(Duration::from_secs(30))
502 .expect("wait for signalled exec")
503 .unwrap_or_else(|| {
504 let _ = child.kill();
505 let _ = child.wait();
506 panic!("signalled exec did not exit");
507 });
508 let _ = join_pipe(stdout_reader, "stdout");
509 let _ = join_pipe(stderr_reader, "stderr");
510
511 assert!(
512 !status.success(),
513 "a signalled exec must exit nonzero (got {status:?})"
514 );
515 wait_for_pid_death(service_pid);
516 }
517
517 lines RUST