diff --git a/src/main.rs b/src/main.rs index 4442aae..1d388f6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -529,6 +529,18 @@ fn resolve_slug_and_branch( Ok((slug, branch, false, None)) } +/// Error when the session is mid-operation — its env and services are in flux. +fn ensure_session_settled(session: &state::Session) -> Result<()> { + if session.status == state::SessionStatus::Pending { + return Err(anyhow::anyhow!( + "session '{}' has an up/down operation in progress; retry when it finishes, or run `ecluse down {}` if it crashed", + session.slug, + session.slug + )); + } + Ok(()) +} + fn resolve_slug_from_args( arg: Option<&str>, guard: &state::StateGuard, @@ -657,38 +669,74 @@ fn cmd_up(args: cli::UpArgs) -> Result<()> { let global = process::load_global_config()?; validate::validate_process_manager(&global.process_manager)?; - let mut guard = state::StateGuard::acquire(&root)?; + let port_overrides: std::collections::HashMap = + args.port_overrides.iter().cloned().collect(); + let service_filter: Option> = + parse_service_filter(&args.services, &config)?; - // Resolve slug + branch: from arg, ecluse worktree state, non-ecluse worktree, or prompt. - let (slug, branch, implicit_reuse, worktree_override) = - resolve_slug_and_branch(&args.slug, &guard, &root)?; + // Resolve slug + branch from a read-only snapshot. Resolution can prompt + // for a branch name; neither the prompt nor the provisioning below may + // run under the exclusive lock, or every other ecluse command in this + // repo blocks on us until it times out. + let (slug, branch, implicit_reuse, worktree_override) = { + let guard = state::StateGuard::acquire_shared(&root)?; + resolve_slug_and_branch(&args.slug, &guard, &root)? + }; validate_branch(&branch)?; - // Resume path: session already exists — restart/skip services idempotently. - if let Some(existing) = guard.state.find_session(&slug).cloned() { - log.step("Looking for existing session..."); - log.detail(&format!( - "found session '{}' (slot {}) — reusing worktree", - slug, existing.slot - )); - return cmd_up_resume(existing, args, config, root, guard, log); - } - - // New session path. - log.step("Allocating slot..."); - let allocator = slot::SlotAllocator::new(&config, &guard.state); - let slot = allocator.allocate_next()?; - log.detail(&format!("slot {slot}")); + // Short exclusive section: route to resume, or reserve the slot with a + // pending session, then release the lock for the slow provisioning work. + let slot = { + let mut guard = state::StateGuard::acquire(&root)?; - let handler = modes::get_handler(&config); + if let Some(existing) = guard.state.find_session(&slug).cloned() { + if existing.status == state::SessionStatus::Pending { + return Err(anyhow::anyhow!( + "session '{slug}' has an operation in progress (started {}); wait for it to finish, or run `ecluse down {slug}` if it crashed", + existing.started_at + )); + } + log.step("Looking for existing session..."); + log.detail(&format!( + "found session '{}' (slot {}) — reusing worktree", + slug, existing.slot + )); + return cmd_up_resume(existing, args, config, root, guard, log); + } - let port_overrides: std::collections::HashMap = - args.port_overrides.iter().cloned().collect(); + log.step("Allocating slot..."); + let allocator = slot::SlotAllocator::new(&config, &guard.state); + let slot = allocator.allocate_next()?; + log.detail(&format!("slot {slot}")); - let service_filter: Option> = - parse_service_filter(&args.services, &config)?; + let planned_worktree = worktree_override.clone().unwrap_or_else(|| { + worktree::WorktreeManager::new(root.clone()).worktree_path(&config, &slug) + }); + guard.state.add_session(state::Session { + slug: slug.clone(), + mode: config.mode.clone(), + slot, + branch: branch.clone(), + worktree_path: planned_worktree.display().to_string(), + status: state::SessionStatus::Pending, + compose_project: None, + overlay_file: None, + overlay_files: vec![], + app_port: None, + started_at: chrono::Utc::now().to_rfc3339(), + port_overrides: std::collections::HashMap::new(), + process_manager: None, + tmux_session: None, + pid_files: vec![], + log_dir: None, + services_subset: None, + }); + guard.commit()?; + slot + }; - let session = handler.bring_up( + let handler = modes::get_handler(&config); + let result = handler.bring_up( &slug, slot, &branch, @@ -703,18 +751,28 @@ fn cmd_up(args: cli::UpArgs) -> Result<()> { &std::collections::HashSet::new(), &std::collections::HashMap::new(), &log, - )?; + ); - if args.json { - print_up_json(&session, &root)?; - } else { - print_up_summary(&session, &config, &log); + // Re-acquire to finalize: replace the pending reservation with the real + // session, or drop it when provisioning failed (bring_up rolled back). + let mut guard = state::StateGuard::acquire(&root)?; + guard.state.remove_session(&slug); + match result { + Ok(session) => { + if args.json { + print_up_json(&session, &root)?; + } else { + print_up_summary(&session, &config, &log); + } + guard.state.add_session(session); + guard.commit()?; + Ok(()) + } + Err(e) => { + guard.commit()?; + Err(e) + } } - - guard.state.add_session(session); - guard.commit()?; - - Ok(()) } /// Validate --services names and build the filter set. @@ -749,6 +807,9 @@ fn parse_service_filter( /// Resume an existing session: restart downed services, skip healthy ones. /// With --force: kill everything first, then start all (minus --skip). +/// +/// The session is marked Pending and the lock released while services are +/// health-checked and started; the entry is restored or replaced when done. fn cmd_up_resume( existing: state::Session, args: cli::UpArgs, @@ -757,10 +818,7 @@ fn cmd_up_resume( mut guard: state::StateGuard, log: log::StepLogger, ) -> Result<()> { - let worktree = std::path::Path::new(&existing.worktree_path); - let handler = modes::get_handler(&config); - - // Build explicit --skip set. + // Build and validate the explicit --skip set before touching state. let explicit_skip: std::collections::HashSet = args .skip .as_deref() @@ -768,8 +826,6 @@ fn cmd_up_resume( .iter() .cloned() .collect(); - - // Validate --skip names. for name in &explicit_skip { if !config.services.iter().any(|s| &s.name == name) { let list = config @@ -787,12 +843,77 @@ fn cmd_up_resume( } } + // Mark pending and release the lock for the health checks + startup. + let mut marked = existing.clone(); + marked.status = state::SessionStatus::Pending; + guard.state.remove_session(&existing.slug); + guard.state.add_session(marked); + guard.commit()?; + drop(guard); + + let outcome = resume_provision(&existing, &args, &config, &root, &explicit_skip, &log); + + // Re-acquire to finalize: replace with the refreshed session, or restore + // the original (still-active) entry when nothing changed or on failure. + let mut guard = state::StateGuard::acquire(&root)?; + guard.state.remove_session(&existing.slug); + match outcome { + Ok(Some((updated, started, skipped))) => { + if !args.quiet && !args.json { + println!(); + log.success(&format!( + "{} service{} started, {} skipped", + started, + if started == 1 { "" } else { "s" }, + skipped + )); + } + if args.json { + print_up_json(&updated, &root)?; + } + guard.state.add_session(updated); + guard.commit()?; + Ok(()) + } + Ok(None) => { + guard.state.add_session(existing.clone()); + guard.commit()?; + log.step("All services already running — nothing to do."); + if args.json { + print_up_json(&existing, &root)?; + } else { + print_up_summary(&existing, &config, &log); + } + Ok(()) + } + Err(e) => { + guard.state.add_session(existing); + guard.commit()?; + Err(e) + } + } +} + +/// Health-check and start services for a resumed session. Runs without the +/// state lock. Returns None when everything is already running, otherwise +/// the refreshed session plus (started, skipped) counts. +fn resume_provision( + existing: &state::Session, + args: &cli::UpArgs, + config: &config::Config, + root: &std::path::Path, + explicit_skip: &std::collections::HashSet, + log: &log::StepLogger, +) -> Result> { + let worktree = std::path::Path::new(&existing.worktree_path); + let handler = modes::get_handler(config); + let mut skip_services: std::collections::HashSet = explicit_skip.clone(); if args.force { // Kill all non-skipped services. log.step("--force: killing services on allocated ports..."); - force_kill_session_services(&existing, &config, &explicit_skip, &log); + force_kill_session_services(existing, config, explicit_skip, log); } else { // Auto-detect already-running services and add them to skip set. log.step("Checking service health..."); @@ -856,25 +977,19 @@ fn cmd_up_resume( let to_start = total.saturating_sub(skipped_count); if to_start == 0 && !args.force { - log.step("All services already running — nothing to do."); - if args.json { - print_up_json(&existing, &root)?; - } else { - print_up_summary(&existing, &config, &log); - } - return Ok(()); + return Ok(None); } let port_overrides: std::collections::HashMap = args.port_overrides.iter().cloned().collect(); - let service_filter = parse_service_filter(&args.services, &config)?; + let service_filter = parse_service_filter(&args.services, config)?; let updated_session = handler.bring_up( &existing.slug, existing.slot, &existing.branch, - &config, - &root, + config, + root, args.watch, true, // always reuse-worktree on resume args.no_inherit_env, @@ -887,30 +1002,10 @@ fn cmd_up_resume( service_filter.as_ref(), &skip_services, &existing.port_overrides, - &log, + log, )?; - if !args.quiet && !args.json { - let started = to_start; - println!(); - log.success(&format!( - "{} service{} started, {} skipped", - started, - if started == 1 { "" } else { "s" }, - skipped_count - )); - } - - if args.json { - print_up_json(&updated_session, &root)?; - } - - // Replace session in state with refreshed version. - guard.state.remove_session(&existing.slug); - guard.state.add_session(updated_session); - guard.commit()?; - - Ok(()) + Ok(Some((updated_session, to_start, skipped_count))) } /// Kill all non-skipped services for a session. @@ -1057,35 +1152,71 @@ fn cmd_down(args: cli::DownArgs) -> Result<()> { log.step("Loading config..."); let (config, root) = config::Config::find_and_load()?; - let mut guard = state::StateGuard::acquire(&root)?; - - let slug = resolve_slug_from_args(args.slug.as_deref(), &guard, "ecluse down ")?; + // Resolve the target from a read-only snapshot; the interactive worktree + // prompt below must never run while holding the exclusive lock. + let slug = { + let guard = state::StateGuard::acquire_shared(&root)?; + resolve_slug_from_args(args.slug.as_deref(), &guard, "ecluse down ")? + }; + // Short exclusive section: re-verify the session and mark it pending so + // the slug + slot stay reserved while teardown runs without the lock. log.step(&format!("Loading session '{slug}'...")); - let session = guard - .state - .find_session(&slug) - .ok_or_else(|| error::EcluseError::SessionNotFound(slug.clone()))? - .clone(); + let session = { + let mut guard = state::StateGuard::acquire(&root)?; + let current = guard + .state + .find_session(&slug) + .ok_or_else(|| error::EcluseError::SessionNotFound(slug.clone()))? + .clone(); + if current.status == state::SessionStatus::Pending { + log.warn(&format!( + "session '{slug}' has an operation in progress (started {}); tearing it down anyway", + current.started_at + )); + } + let mut marked = current.clone(); + marked.status = state::SessionStatus::Pending; + guard.state.remove_session(&slug); + guard.state.add_session(marked); + guard.commit()?; + current + }; log.detail(&format!("slot {}, mode: {}", session.slot, session.mode)); - let keep_worktree = resolve_worktree_keep( + let keep_worktree = match resolve_worktree_keep( std::path::Path::new(&session.worktree_path), args.keep_worktree, args.delete_worktree, - )?; + ) { + Ok(k) => k, + Err(e) => { + // Aborted at the prompt — restore the session before bailing out. + restore_session(&root, &session)?; + return Err(e); + } + }; let handler = modes::get_handler(&config); - handler.bring_down( + let result = handler.bring_down( &session, &config, &root, args.keep_volumes, keep_worktree, &log, - )?; + ); + let mut guard = state::StateGuard::acquire(&root)?; guard.state.remove_session(&slug); + if let Err(e) = result { + // Teardown failed — keep the session visible so it can be retried. + let mut restored = session; + restored.status = state::SessionStatus::Active; + guard.state.add_session(restored); + guard.commit()?; + return Err(e); + } guard.commit()?; if args.keep_branch { @@ -1109,6 +1240,17 @@ fn cmd_down(args: cli::DownArgs) -> Result<()> { Ok(()) } +/// Put a session back into state with Active status (used when an operation +/// that marked it Pending aborts or fails without changing anything durable). +fn restore_session(root: &std::path::Path, session: &state::Session) -> Result<()> { + let mut guard = state::StateGuard::acquire(root)?; + guard.state.remove_session(&session.slug); + let mut restored = session.clone(); + restored.status = state::SessionStatus::Active; + guard.state.add_session(restored); + guard.commit() +} + // ── shutdown ────────────────────────────────────────────────────────────────── fn cmd_shutdown(args: cli::ShutdownArgs) -> Result<()> { @@ -1117,14 +1259,18 @@ fn cmd_shutdown(args: cli::ShutdownArgs) -> Result<()> { log.step("Loading config..."); let (config, root) = config::Config::find_and_load()?; - let mut guard = state::StateGuard::acquire(&root)?; + // Work from a snapshot; each session is marked pending under a short + // exclusive section so prompts and teardown never hold the lock. + let sessions: Vec = { + let guard = state::StateGuard::acquire_shared(&root)?; + guard.state.sessions.clone() + }; - if guard.state.sessions.is_empty() { + if sessions.is_empty() { println!("no active sessions"); return Ok(()); } - let sessions: Vec = guard.state.sessions.clone(); let total = sessions.len(); let handler = modes::get_handler(&config); let mut failed: Vec = Vec::new(); @@ -1146,14 +1292,36 @@ fn cmd_shutdown(args: cli::ShutdownArgs) -> Result<()> { } }; - match handler.bring_down(&session, &config, &root, args.keep_volumes, keep_wt, &log) { + // Re-verify under the lock (another command may have removed it) and + // mark pending for the unlocked teardown. + let current = { + let mut guard = state::StateGuard::acquire(&root)?; + match guard.state.find_session(&session.slug).cloned() { + None => { + log.detail("already removed — skipped"); + continue; + } + Some(current) => { + let mut marked = current.clone(); + marked.status = state::SessionStatus::Pending; + guard.state.remove_session(&session.slug); + guard.state.add_session(marked); + guard.commit()?; + current + } + } + }; + + match handler.bring_down(¤t, &config, &root, args.keep_volumes, keep_wt, &log) { Ok(()) => { - guard.state.remove_session(&session.slug); + let mut guard = state::StateGuard::acquire(&root)?; + guard.state.remove_session(¤t.slug); guard.commit()?; } Err(e) => { - log.warn(&format!("'{}' failed: {}", session.slug, e)); - failed.push(session.slug.clone()); + log.warn(&format!("'{}' failed: {}", current.slug, e)); + failed.push(current.slug.clone()); + restore_session(&root, ¤t)?; } } } @@ -1244,7 +1412,11 @@ fn cmd_ls(args: cli::LsArgs) -> Result<()> { pairs.join(" ") }; SessionRow { - slug: s.slug.clone(), + slug: if s.status == state::SessionStatus::Pending { + format!("{} (pending)", s.slug) + } else { + s.slug.clone() + }, mode: s.mode.to_string(), slot: s.slot, ports, @@ -1298,6 +1470,7 @@ fn cmd_shell(args: cli::ShellArgs) -> Result<()> { .find_session(&slug) .ok_or_else(|| error::EcluseError::SessionNotFound(slug.clone()))? .clone(); + ensure_session_settled(&session)?; let worktree = std::path::Path::new(&session.worktree_path); let env_file = worktree.join(".env.ecluse"); @@ -1426,6 +1599,7 @@ fn cmd_env(args: cli::EnvArgs) -> Result<()> { .find_session(&slug) .ok_or_else(|| error::EcluseError::SessionNotFound(slug.clone()))? .clone(); + ensure_session_settled(&session)?; let env_file = std::path::Path::new(&session.worktree_path).join(".env.ecluse"); @@ -1641,6 +1815,7 @@ fn cmd_sync(args: cli::SyncArgs) -> Result<()> { slot, branch, worktree_path: worktree_path.display().to_string(), + status: state::SessionStatus::Active, app_port, port_overrides, process_manager: Some(process::ProcessManager::Nohup), @@ -1898,6 +2073,7 @@ fn cmd_status(args: cli::StatusArgs) -> Result<()> { .find_session(&slug) .ok_or_else(|| error::EcluseError::SessionNotFound(slug.clone()))? .clone(); + ensure_session_settled(&session)?; let worktree = std::path::Path::new(&session.worktree_path); diff --git a/src/modes/container.rs b/src/modes/container.rs index 578bba6..6308dba 100644 --- a/src/modes/container.rs +++ b/src/modes/container.rs @@ -319,6 +319,7 @@ impl super::ModeHandler for ContainerMode { slot, branch: branch.to_string(), worktree_path: worktree_path.display().to_string(), + status: crate::state::SessionStatus::Active, compose_project: Some(project), overlay_file: primary_overlay, overlay_files: extra_overlays, diff --git a/src/modes/host.rs b/src/modes/host.rs index e6c84da..79bc75e 100644 --- a/src/modes/host.rs +++ b/src/modes/host.rs @@ -162,6 +162,7 @@ impl super::ModeHandler for HostMode { slot, branch: branch.to_string(), worktree_path: worktree_path.display().to_string(), + status: crate::state::SessionStatus::Active, compose_project: None, overlay_file: None, overlay_files: vec![], diff --git a/src/modes/hybrid.rs b/src/modes/hybrid.rs index e3fda02..75ac589 100644 --- a/src/modes/hybrid.rs +++ b/src/modes/hybrid.rs @@ -434,6 +434,7 @@ impl super::ModeHandler for HybridMode { slot, branch: branch.to_string(), worktree_path: worktree_path.display().to_string(), + status: crate::state::SessionStatus::Active, compose_project: Some(project), overlay_file: primary_overlay, overlay_files: extra_overlays, diff --git a/src/slot.rs b/src/slot.rs index 50cef5d..8df8563 100644 --- a/src/slot.rs +++ b/src/slot.rs @@ -37,6 +37,7 @@ mod tests { slot, branch: format!("branch-{}", slot), worktree_path: format!("/tmp/wt-{}", slot), + status: crate::state::SessionStatus::Active, compose_project: None, overlay_file: None, overlay_files: vec![], diff --git a/src/state.rs b/src/state.rs index 1e96958..ab513b7 100644 --- a/src/state.rs +++ b/src/state.rs @@ -29,6 +29,25 @@ fn default_version() -> u8 { 1 } +/// Lifecycle state of a session entry. +/// +/// `Pending` reserves the slug + slot while an `up`/`down` runs *without* +/// holding the state lock — provisioning can take minutes (image pulls, +/// hooks) and must not block every other ecluse command. A `Pending` entry +/// that never transitions back means the operation crashed; `ecluse down +/// ` cleans it up. +#[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum SessionStatus { + #[default] + Active, + Pending, +} + +fn is_active(status: &SessionStatus) -> bool { + *status == SessionStatus::Active +} + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Session { pub slug: String, @@ -36,6 +55,10 @@ pub struct Session { pub slot: u8, pub branch: String, pub worktree_path: String, + /// Active, or Pending while an up/down is in flight. Defaults to Active + /// so state.json files written by older versions load unchanged. + #[serde(default, skip_serializing_if = "is_active")] + pub status: SessionStatus, pub compose_project: Option, pub overlay_file: Option, /// Additional overlay files when multiple compose files are involved (monorepo). @@ -243,6 +266,7 @@ mod tests { slot, branch: format!("branch/{}", slug), worktree_path: format!("/tmp/{}", slug), + status: SessionStatus::Active, compose_project: None, overlay_file: None, overlay_files: vec![], @@ -421,6 +445,7 @@ mod tests { slot: 1, branch: "branch/pm-sess".into(), worktree_path: "/tmp/pm-sess".into(), + status: SessionStatus::Active, compose_project: None, overlay_file: None, overlay_files: vec![], @@ -455,6 +480,7 @@ mod tests { slot: 2, branch: "branch/nohup-sess".into(), worktree_path: "/tmp/nohup-sess".into(), + status: SessionStatus::Active, compose_project: None, overlay_file: None, overlay_files: vec![], @@ -486,6 +512,7 @@ mod tests { slot: 1, branch: "branch/compose-sess".into(), worktree_path: "/tmp/wt".into(), + status: SessionStatus::Active, compose_project: Some("ecluse_compose-sess".into()), overlay_file: Some("/tmp/overlay.yml".into()), overlay_files: vec![], @@ -530,4 +557,43 @@ mod tests { let json = serde_json::to_string(&s).unwrap(); assert!(!json.contains("services_subset"), "got: {json}"); } + + // ── SessionStatus ───────────────────────────────────────────────────────── + + #[test] + fn active_status_not_serialized() { + // Keeps state.json byte-compatible with older versions for active sessions. + let s = make_session("plain", 1); + let json = serde_json::to_string(&s).unwrap(); + assert!(!json.contains("status"), "got: {json}"); + } + + #[test] + fn pending_status_roundtrips() { + let mut s = make_session("busy", 1); + s.status = SessionStatus::Pending; + let json = serde_json::to_string(&s).unwrap(); + assert!(json.contains("pending"), "got: {json}"); + let back: Session = serde_json::from_str(&json).unwrap(); + assert_eq!(back.status, SessionStatus::Pending); + } + + #[test] + fn session_without_status_field_defaults_to_active() { + // state.json written by an older ecluse has no status field. + let s = make_session("old", 1); + let mut json: serde_json::Value = serde_json::to_value(&s).unwrap(); + json.as_object_mut().unwrap().remove("status"); + let back: Session = serde_json::from_value(json).unwrap(); + assert_eq!(back.status, SessionStatus::Active); + } + + #[test] + fn pending_sessions_still_reserve_slots() { + let mut state = State::default(); + let mut s = make_session("busy", 3); + s.status = SessionStatus::Pending; + state.add_session(s); + assert!(state.used_slots().contains(&3)); + } } diff --git a/src/whose_pid.rs b/src/whose_pid.rs index 6a2b8c0..92049ef 100644 --- a/src/whose_pid.rs +++ b/src/whose_pid.rs @@ -149,6 +149,7 @@ mod tests { slot, branch: format!("branch/{}", slug), worktree_path: format!("/tmp/{}", slug), + status: crate::state::SessionStatus::Active, compose_project: None, overlay_file: None, overlay_files: vec![], diff --git a/tests/integration.rs b/tests/integration.rs index 64a03cd..64bdc50 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -448,3 +448,154 @@ fn up_without_init_errors() { stderr(&out) ); } + +// ── pending sessions: lock is not held during provisioning ──────────────────── + +#[test] +fn ls_works_while_up_is_provisioning() { + let repo = tmp_repo(); + ecluse(repo.path(), &["init", "--mode", "host", "--yes"]); + // A slow post_up hook simulates image pulls / migrations. + std::fs::write( + repo.path().join(".ecluse.toml"), + r#"mode = "host" +max_slots = 8 +prefix = "ecluse" +worktree_dir = ".ecluse/worktrees" +inherit_env = [] + +[hooks] +post_up = "sleep 3" +"#, + ) + .unwrap(); + + let mut slow_up = Command::new(ecluse_bin()) + .args(["up", "slow-sess"]) + .current_dir(repo.path()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .unwrap(); + + // Wait until the pending reservation is committed. + let state_path = repo.path().join(".ecluse/state.json"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + loop { + if state_path.exists() + && std::fs::read_to_string(&state_path) + .unwrap_or_default() + .contains("slow-sess") + { + break; + } + assert!( + std::time::Instant::now() < deadline, + "pending session never appeared in state.json" + ); + std::thread::sleep(std::time::Duration::from_millis(50)); + } + + // While `up` sleeps in its post_up hook, read commands must not block + // on the lock — this timed out after 10s before pending sessions. + let start = std::time::Instant::now(); + let out = ecluse(repo.path(), &["ls"]); + assert!(out.status.success(), "{}", stderr(&out)); + assert!( + start.elapsed() < std::time::Duration::from_secs(5), + "ls blocked while up was provisioning" + ); + assert!( + stdout(&out).contains("slow-sess") && stdout(&out).contains("(pending)"), + "got: {}", + stdout(&out) + ); + + let status = slow_up.wait().unwrap(); + assert!(status.success()); + + // After up finishes, the session is active — no pending marker. + let out = ecluse(repo.path(), &["ls"]); + assert!(stdout(&out).contains("slow-sess")); + assert!(!stdout(&out).contains("(pending)"), "got: {}", stdout(&out)); + + ecluse(repo.path(), &["down", "--delete-worktree", "slow-sess"]); +} + +#[test] +fn up_on_pending_session_errors_actionably() { + let repo = tmp_repo(); + ecluse(repo.path(), &["init", "--mode", "host", "--yes"]); + std::fs::write( + repo.path().join(".ecluse.toml"), + r#"mode = "host" +max_slots = 8 +prefix = "ecluse" +worktree_dir = ".ecluse/worktrees" +inherit_env = [] + +[hooks] +post_up = "sleep 3" +"#, + ) + .unwrap(); + + let mut slow_up = Command::new(ecluse_bin()) + .args(["up", "busy-sess"]) + .current_dir(repo.path()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .unwrap(); + + let state_path = repo.path().join(".ecluse/state.json"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while !std::fs::read_to_string(&state_path) + .unwrap_or_default() + .contains("busy-sess") + { + assert!(std::time::Instant::now() < deadline); + std::thread::sleep(std::time::Duration::from_millis(50)); + } + + let out = ecluse(repo.path(), &["up", "busy-sess"]); + assert!(!out.status.success(), "second up must not race the first"); + assert!( + stderr(&out).contains("operation in progress"), + "got: {}", + stderr(&out) + ); + + slow_up.wait().unwrap(); + ecluse(repo.path(), &["down", "--delete-worktree", "busy-sess"]); +} + +#[test] +fn failed_up_removes_pending_reservation() { + let repo = tmp_repo(); + ecluse(repo.path(), &["init", "--mode", "host", "--yes"]); + std::fs::write( + repo.path().join(".ecluse.toml"), + r#"mode = "host" +max_slots = 8 +prefix = "ecluse" +worktree_dir = ".ecluse/worktrees" +inherit_env = [] + +[hooks] +post_up = "false" +"#, + ) + .unwrap(); + + let out = ecluse(repo.path(), &["up", "doomed"]); + assert!(!out.status.success()); + + // The pending reservation must be gone: slot freed, ls empty. + let out = ecluse(repo.path(), &["ls"]); + assert!( + stdout(&out).contains("no active sessions"), + "got: {}", + stdout(&out) + ); +}