Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
364 changes: 270 additions & 94 deletions src/main.rs

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src/modes/container.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/modes/host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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![],
Expand Down
1 change: 1 addition & 0 deletions src/modes/hybrid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/slot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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![],
Expand Down
66 changes: 66 additions & 0 deletions src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,36 @@ 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
/// <slug>` 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,
pub mode: Mode,
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<String>,
pub overlay_file: Option<String>,
/// Additional overlay files when multiple compose files are involved (monorepo).
Expand Down Expand Up @@ -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![],
Expand Down Expand Up @@ -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![],
Expand Down Expand Up @@ -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![],
Expand Down Expand Up @@ -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![],
Expand Down Expand Up @@ -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));
}
}
1 change: 1 addition & 0 deletions src/whose_pid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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![],
Expand Down
151 changes: 151 additions & 0 deletions tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);
}
Loading