Skip to content

Commit 167ef69

Browse files
committed
Merge pull request #18 from hefgi/claude/magical-einstein-7m4g40-process
fix(process): kill the whole process group on teardown; per-slug tmux env preamble https://claude.ai/code/session_017UcuvzMKHVfyBCcq8ipAko
2 parents 6ba7bc3 + ce6c960 commit 167ef69

4 files changed

Lines changed: 243 additions & 59 deletions

File tree

src/main.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1814,7 +1814,7 @@ fn cmd_flush(args: cli::FlushArgs) -> Result<()> {
18141814

18151815
// Step 5: wipe .ecluse subdirs.
18161816
let ecluse_dir = root.join(".ecluse");
1817-
for subdir in &["pids", "logs", "overlays"] {
1817+
for subdir in &["pids", "logs", "overlays", "preambles"] {
18181818
let path = ecluse_dir.join(subdir);
18191819
if path.exists() {
18201820
log.detail(&format!(" remove {}", path.display()));

src/modes/host.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,7 @@ impl super::ModeHandler for HostMode {
222222
log.step(&format!("Killing native services ({pm})..."));
223223
process::kill_services(pm, &session.spawn_result());
224224
}
225+
process::remove_env_preamble(std::path::Path::new(&session.worktree_path), &session.slug);
225226

226227
if !keep_worktree {
227228
log.step("Removing worktree...");

src/modes/hybrid.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,7 @@ impl super::ModeHandler for HybridMode {
515515
log.step(&format!("Killing native services ({pm})..."));
516516
process::kill_services(pm, &session.spawn_result());
517517
}
518+
process::remove_env_preamble(std::path::Path::new(&session.worktree_path), &session.slug);
518519

519520
if let Some(project) = &session.compose_project {
520521
let all_overlays: Vec<String> = session

src/process.rs

Lines changed: 240 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -228,20 +228,42 @@ fn build_source_preamble(worktree: &Path) -> String {
228228
.join("; ")
229229
}
230230

231+
/// Root `.ecluse` directory for a worktree: nearest ancestor containing one,
232+
/// falling back to the worktree itself (externally-registered worktrees).
233+
fn ecluse_dir_for(worktree: &Path) -> PathBuf {
234+
worktree
235+
.ancestors()
236+
.find(|p| p.join(".ecluse").exists())
237+
.unwrap_or(worktree)
238+
.join(".ecluse")
239+
}
240+
241+
/// Path of the per-session env preamble sourced by tmux windows.
242+
/// Namespaced by slug — a shared file would be overwritten by the next
243+
/// session's spawn and leak its ports into manual restarts here.
244+
pub fn env_preamble_path(worktree: &Path, slug: &str) -> PathBuf {
245+
ecluse_dir_for(worktree)
246+
.join("preambles")
247+
.join(format!("{}.sh", slug))
248+
}
249+
250+
/// Best-effort removal of a session's env preamble at teardown.
251+
pub fn remove_env_preamble(worktree: &Path, slug: &str) {
252+
let _ = std::fs::remove_file(env_preamble_path(worktree, slug));
253+
}
254+
231255
fn write_env_preamble_file(
232256
worktree: &Path,
257+
slug: &str,
233258
env: &std::collections::HashMap<String, String>,
234259
) -> Option<std::path::PathBuf> {
235-
// Write merged env as a sourceable file into .ecluse/ so tmux windows can source it
260+
// Write merged env as a sourceable file so tmux windows can source it
236261
// without sending a multi-KB export string through send-keys (which corrupts for
237262
// large envs due to terminal line-length limits and key-event reordering).
238-
let ecluse_dir = worktree
239-
.ancestors()
240-
.find(|p| p.join(".ecluse").exists())
241-
.unwrap_or(worktree)
242-
.join(".ecluse");
243-
let _ = std::fs::create_dir_all(&ecluse_dir);
244-
let preamble_path = ecluse_dir.join("env-preamble.sh");
263+
let preamble_path = env_preamble_path(worktree, slug);
264+
if let Some(parent) = preamble_path.parent() {
265+
let _ = std::fs::create_dir_all(parent);
266+
}
245267
let mut lines: Vec<String> = env
246268
.iter()
247269
.map(|(k, v)| format!("export {}={}", k, shell_escape(v)))
@@ -264,7 +286,7 @@ fn spawn_tmux(
264286

265287
// Write merged env to a file so tmux windows source it rather than receiving
266288
// a multi-KB export string through send-keys (safe for any env size).
267-
let preamble_path = write_env_preamble_file(worktree, &merged_env);
289+
let preamble_path = write_env_preamble_file(worktree, slug, &merged_env);
268290

269291
// Build the source preamble: ecluse preamble file first, then the worktree env
270292
// files (.env → .env.local → .env.ecluse) so manual restarts (↑ Enter) also
@@ -368,58 +390,29 @@ fn spawn_nohup(
368390
worktree: &Path,
369391
env: &std::collections::HashMap<String, String>,
370392
) -> Result<SpawnResult> {
371-
use std::fs::File;
372-
use std::os::unix::process::CommandExt;
373-
374-
let log_dir = worktree
375-
.ancestors()
376-
.find(|p| p.join(".ecluse").exists())
377-
.unwrap_or(worktree)
378-
.join(".ecluse")
379-
.join("logs")
380-
.join(slug);
381-
let pid_dir = worktree
382-
.ancestors()
383-
.find(|p| p.join(".ecluse").exists())
384-
.unwrap_or(worktree)
385-
.join(".ecluse")
386-
.join("pids")
387-
.join(slug);
393+
let ecluse_dir = ecluse_dir_for(worktree);
394+
let log_dir = ecluse_dir.join("logs").join(slug);
395+
let pid_dir = ecluse_dir.join("pids").join(slug);
388396

389397
std::fs::create_dir_all(&log_dir)?;
390398
std::fs::create_dir_all(&pid_dir)?;
391399

392400
let merged_env = merge_worktree_env(worktree, env);
393-
let mut pid_files = vec![];
401+
let mut pid_files: Vec<PathBuf> = vec![];
394402

395403
for svc in services {
396-
let cmd = svc.command.as_deref().unwrap();
397-
let log_path = log_dir.join(format!("{}.log", svc.name));
398-
let pid_path = pid_dir.join(format!("{}.pid", svc.name));
399-
400-
let log_file =
401-
File::create(&log_path).map_err(|e| crate::error::EcluseError::SpawnFailed {
402-
service: svc.name.clone(),
403-
reason: format!("could not create log file: {}", e),
404-
})?;
405-
let log_file2 = log_file.try_clone()?;
406-
407-
let child = Command::new("sh")
408-
.arg("-c")
409-
.arg(cmd)
410-
.current_dir(worktree)
411-
.envs(&merged_env)
412-
.stdout(log_file)
413-
.stderr(log_file2)
414-
.process_group(0)
415-
.spawn()
416-
.map_err(|e| crate::error::EcluseError::SpawnFailed {
417-
service: svc.name.clone(),
418-
reason: e.to_string(),
419-
})?;
420-
421-
std::fs::write(&pid_path, child.id().to_string())?;
422-
pid_files.push(pid_path);
404+
match spawn_one_nohup(svc, worktree, &merged_env, &log_dir, &pid_dir) {
405+
Ok(pid_path) => pid_files.push(pid_path),
406+
Err(e) => {
407+
// A partial spawn must not leave orphans: kill what already started.
408+
kill_nohup(&SpawnResult {
409+
tmux_session: None,
410+
pid_files,
411+
log_dir: Some(log_dir.clone()),
412+
});
413+
return Err(e);
414+
}
415+
}
423416
}
424417

425418
Ok(SpawnResult {
@@ -429,21 +422,84 @@ fn spawn_nohup(
429422
})
430423
}
431424

425+
fn spawn_one_nohup(
426+
svc: &ServiceConfig,
427+
worktree: &Path,
428+
env: &std::collections::HashMap<String, String>,
429+
log_dir: &Path,
430+
pid_dir: &Path,
431+
) -> Result<PathBuf> {
432+
use std::fs::File;
433+
use std::os::unix::process::CommandExt;
434+
435+
let cmd = svc.command.as_deref().unwrap();
436+
let log_path = log_dir.join(format!("{}.log", svc.name));
437+
let pid_path = pid_dir.join(format!("{}.pid", svc.name));
438+
439+
let log_file = File::create(&log_path).map_err(|e| crate::error::EcluseError::SpawnFailed {
440+
service: svc.name.clone(),
441+
reason: format!("could not create log file: {}", e),
442+
})?;
443+
let log_file2 = log_file.try_clone()?;
444+
445+
let child = Command::new("sh")
446+
.arg("-c")
447+
.arg(cmd)
448+
.current_dir(worktree)
449+
.envs(env)
450+
.stdout(log_file)
451+
.stderr(log_file2)
452+
.process_group(0)
453+
.spawn()
454+
.map_err(|e| crate::error::EcluseError::SpawnFailed {
455+
service: svc.name.clone(),
456+
reason: e.to_string(),
457+
})?;
458+
459+
std::fs::write(&pid_path, child.id().to_string())?;
460+
Ok(pid_path)
461+
}
462+
432463
fn kill_nohup(result: &SpawnResult) {
433464
for pid_file in &result.pid_files {
434465
if let Ok(content) = std::fs::read_to_string(pid_file) {
435466
if let Ok(pid) = content.trim().parse::<u32>() {
436-
Command::new("kill")
437-
.args(["-TERM", &pid.to_string()])
438-
.output()
439-
.ok();
467+
kill_process_group(pid);
440468
}
441469
}
442470
// Remove PID file regardless of kill success
443471
let _ = std::fs::remove_file(pid_file);
444472
}
445473
}
446474

475+
/// TERM an entire process group, escalating to KILL if anything survives the
476+
/// grace period. spawn_nohup runs each service in its own group
477+
/// (process_group(0), pgid == leader pid); signaling only the leader would
478+
/// orphan the service's children — the `sh -c` wrapper dies while the actual
479+
/// server keeps running and holds the port.
480+
fn kill_process_group(pgid: u32) {
481+
let group = format!("-{}", pgid);
482+
let _ = Command::new("kill").args(["-TERM", "--", &group]).output();
483+
484+
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
485+
while process_group_alive(pgid) {
486+
if std::time::Instant::now() >= deadline {
487+
let _ = Command::new("kill").args(["-KILL", "--", &group]).output();
488+
return;
489+
}
490+
std::thread::sleep(std::time::Duration::from_millis(50));
491+
}
492+
}
493+
494+
/// True while any process in the group still exists (kill -0 on the group).
495+
fn process_group_alive(pgid: u32) -> bool {
496+
Command::new("kill")
497+
.args(["-0", "--", &format!("-{}", pgid)])
498+
.output()
499+
.map(|o| o.status.success())
500+
.unwrap_or(false)
501+
}
502+
447503
#[cfg(test)]
448504
mod tests {
449505
use super::*;
@@ -657,4 +713,130 @@ mod tests {
657713
let merged = merge_worktree_env(dir.path(), &base);
658714
assert_eq!(merged.get("KEY").map(String::as_str), Some("from-local"));
659715
}
716+
717+
fn native_svc(name: &str, command: &str) -> crate::config::ServiceConfig {
718+
crate::config::ServiceConfig {
719+
name: name.into(),
720+
base_port: 3000,
721+
run: crate::config::ServiceRun::Native,
722+
compose: None,
723+
command: Some(command.into()),
724+
port_env: vec![],
725+
debug_port: None,
726+
extra_ports: vec![],
727+
host_port: None,
728+
}
729+
}
730+
731+
fn wait_until(timeout: std::time::Duration, mut cond: impl FnMut() -> bool) -> bool {
732+
let deadline = std::time::Instant::now() + timeout;
733+
while std::time::Instant::now() < deadline {
734+
if cond() {
735+
return true;
736+
}
737+
std::thread::sleep(std::time::Duration::from_millis(50));
738+
}
739+
cond()
740+
}
741+
742+
// The service command spawns a child; killing the session must take the
743+
// whole process group down, not just the `sh -c` group leader.
744+
#[test]
745+
fn kill_nohup_kills_whole_process_group() {
746+
let dir = TempDir::new().unwrap();
747+
std::fs::create_dir_all(dir.path().join(".ecluse")).unwrap();
748+
let child_pid_file = dir.path().join("child.pid");
749+
let svc = native_svc(
750+
"bg",
751+
&format!("sleep 30 & echo $! > {}; wait", child_pid_file.display()),
752+
);
753+
let result = spawn_services(
754+
&ProcessManager::Nohup,
755+
"pg-test",
756+
&[&svc],
757+
dir.path(),
758+
&std::collections::HashMap::new(),
759+
)
760+
.unwrap();
761+
762+
assert!(
763+
wait_until(std::time::Duration::from_secs(5), || child_pid_file
764+
.exists()),
765+
"child pid file never appeared"
766+
);
767+
let child_pid: u32 = std::fs::read_to_string(&child_pid_file)
768+
.unwrap()
769+
.trim()
770+
.parse()
771+
.unwrap();
772+
assert!(pid_alive(child_pid), "background child should be running");
773+
774+
kill_services(&ProcessManager::Nohup, &result);
775+
776+
assert!(
777+
wait_until(std::time::Duration::from_secs(5), || !pid_alive(child_pid)),
778+
"background child must die with the process group"
779+
);
780+
}
781+
782+
// Preamble files are per-slug; parallel sessions must never share one.
783+
#[test]
784+
fn env_preamble_file_is_namespaced_per_slug() {
785+
let dir = TempDir::new().unwrap();
786+
std::fs::create_dir_all(dir.path().join(".ecluse")).unwrap();
787+
788+
let mut env_a = std::collections::HashMap::new();
789+
env_a.insert("PORT".to_string(), "3001".to_string());
790+
let path_a = write_env_preamble_file(dir.path(), "sess-a", &env_a).unwrap();
791+
792+
let mut env_b = std::collections::HashMap::new();
793+
env_b.insert("PORT".to_string(), "3002".to_string());
794+
let path_b = write_env_preamble_file(dir.path(), "sess-b", &env_b).unwrap();
795+
796+
assert_ne!(path_a, path_b);
797+
assert!(std::fs::read_to_string(&path_a).unwrap().contains("3001"));
798+
assert!(std::fs::read_to_string(&path_b).unwrap().contains("3002"));
799+
}
800+
801+
#[test]
802+
fn remove_env_preamble_deletes_only_that_slug() {
803+
let dir = TempDir::new().unwrap();
804+
std::fs::create_dir_all(dir.path().join(".ecluse")).unwrap();
805+
let mut env = std::collections::HashMap::new();
806+
env.insert("PORT".to_string(), "3001".to_string());
807+
let path_a = write_env_preamble_file(dir.path(), "sess-a", &env).unwrap();
808+
let path_b = write_env_preamble_file(dir.path(), "sess-b", &env).unwrap();
809+
810+
remove_env_preamble(dir.path(), "sess-a");
811+
assert!(!path_a.exists());
812+
assert!(path_b.exists());
813+
}
814+
815+
// If service N fails to spawn, services 1..N-1 must be killed, not orphaned.
816+
#[test]
817+
fn spawn_nohup_partial_failure_cleans_up_already_spawned() {
818+
let dir = TempDir::new().unwrap();
819+
std::fs::create_dir_all(dir.path().join(".ecluse")).unwrap();
820+
// Make the second service's log file uncreatable: a directory in its place.
821+
std::fs::create_dir_all(dir.path().join(".ecluse/logs/part/two.log")).unwrap();
822+
823+
let one = native_svc("one", "sleep 30");
824+
let two = native_svc("two", "sleep 30");
825+
let err = spawn_services(
826+
&ProcessManager::Nohup,
827+
"part",
828+
&[&one, &two],
829+
dir.path(),
830+
&std::collections::HashMap::new(),
831+
)
832+
.unwrap_err();
833+
assert!(err.to_string().contains("two"), "got: {}", err);
834+
835+
// kill_nohup removes pid files after killing — service one must be cleaned up.
836+
let one_pid = dir.path().join(".ecluse/pids/part/one.pid");
837+
assert!(
838+
!one_pid.exists(),
839+
"service one's pid file should be removed by partial-spawn cleanup"
840+
);
841+
}
660842
}

0 commit comments

Comments
 (0)