wsb 0.251.6311

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
use super::*;

fn workspace() -> tempfile::TempDir {
    tempfile::tempdir().expect("a temporary workspace")
}

/// A holder record for `pid`, written as the lock file, as another process
/// would have written it.
fn held_by(root: &Path, pid: u32, reason: &str) {
    let holder = Holder {
        command: "wsb test --on pve".into(),
        argv: vec!["wsb".into(), "test".into()],
        pid,
        host: host_name(),
        taken_at: "2026-10-04T21:00:00Z".into(),
        reason: reason.into(),
        protects: "the workspace".into(),
    };
    std::fs::create_dir_all(root.join(".wsb")).unwrap();
    std::fs::write(path(root), serde_json::to_string(&holder).unwrap()).unwrap();
}

/// This process, which is alive for as long as the test runs: a lock held by
/// it is held, not stale.
fn alive() -> u32 {
    std::process::id()
}

// TEST12484: a writer waits while the workspace is locked, and goes on once
// the snapshot releases it.
#[test]
fn test12484_a_writer_waits_for_the_lock_and_proceeds_when_it_is_released() {
    let root = workspace();
    held_by(root.path(), alive(), "snapshotting for run-1");
    let lock = path(root.path());
    let release = std::thread::spawn(move || {
        std::thread::sleep(Duration::from_millis(2500));
        std::fs::remove_file(lock).unwrap();
    });
    let started = Instant::now();
    wait_until_free_with(root.path(), "checkpoint", false, Duration::from_secs(30)).expect("released");
    assert!(started.elapsed() >= Duration::from_secs(2), "it did not wait for the lock");
    release.join().unwrap();
}

// TEST12485: a lock held past the cap fails the writer, naming who holds it,
// why, and the file to delete.
#[test]
fn test12485_a_lock_held_past_the_cap_is_a_failure_naming_its_holder() {
    let root = workspace();
    held_by(root.path(), alive(), "snapshotting for run-2");
    let refused = wait_until_free_with(root.path(), "checkpoint", false, Duration::from_secs(1))
        .expect_err("still locked at the cap");
    for said in ["wsb test --on pve", "snapshotting for run-2", FILE, "--ignore-workspace-lock"] {
        assert!(refused.contains(said), "`{said}` missing from: {refused}");
    }
}

// TEST12486: `--ignore-workspace-lock` proceeds through a held lock.
#[test]
fn test12486_the_override_proceeds_through_a_held_lock() {
    let root = workspace();
    held_by(root.path(), alive(), "snapshotting for run-3");
    let started = Instant::now();
    wait_until_free_with(root.path(), "checkpoint", true, Duration::from_secs(30)).expect("ignored");
    assert!(started.elapsed() < Duration::from_secs(2), "the override waited");
    assert!(path(root.path()).exists(), "the override removed somebody else's lock");
}

// TEST12487: a lock whose holder is gone is reported stale, not waited out
// and not silently ignored.
#[test]
fn test12487_a_lock_whose_holder_is_gone_is_stale() {
    let root = workspace();
    // A pid that is not running: a short-lived child's, reaped before we
    // look.
    let child = crate::git_backend::command_without_repo_scope(std::env::current_exe().unwrap())
        .arg("--list")
        .stdout(std::process::Stdio::null())
        .spawn()
        .unwrap();
    let gone = child.id();
    let mut child = child;
    child.wait().unwrap();
    held_by(root.path(), gone, "snapshotting for run-4");
    let refused = wait_until_free_with(root.path(), "checkpoint", false, Duration::from_secs(30))
        .expect_err("a stale lock is reported");
    assert!(refused.contains("stale") && refused.contains(FILE), "{refused}");
}

// TEST12488: the lock is one holder's at a time, and letting it go releases
// only the holder's own: a lock somebody else holds by then is left alone.
#[test]
fn test12488_the_lock_is_exclusive_and_released_only_by_its_holder() {
    let root = workspace();
    let held = take_within(root.path(), "wsb test", "snapshotting for run-5", Duration::from_secs(1))
        .expect("free, so taken");
    assert!(path(root.path()).exists());
    let second = take_within(root.path(), "wsb test", "another snapshot", Duration::from_secs(1));
    assert!(second.is_err(), "a second holder took a held lock");
    // And at the create itself, where two snapshots that both finished
    // waiting race: only the first makes the file, and the holder in it stays.
    let before = std::fs::read_to_string(path(root.path())).unwrap();
    assert!(!create_exclusively(&path(root.path()), "a racing snapshot").unwrap(), "the create was not exclusive");
    assert_eq!(std::fs::read_to_string(path(root.path())).unwrap(), before, "the racing create overwrote the holder");
    drop(held);
    assert!(!path(root.path()).exists(), "letting the lock go did not release it");

    // Released by the process it was handed to, then taken by another: the
    // first holder's drop leaves the new holder's lock where it is.
    let held = take_within(root.path(), "wsb test", "snapshotting for run-6", Duration::from_secs(1)).unwrap();
    std::fs::remove_file(held.path()).unwrap();
    held_by(root.path(), alive() + 1_000_000, "a later snapshot");
    drop(held);
    assert!(path(root.path()).exists(), "a holder released a lock that was no longer its own");
}

/// A writer's registration for `pid`, as that process would have written it.
fn writing_as(root: &Path, pid: u32) -> PathBuf {
    let record = Holder {
        command: "checkpoint".into(),
        argv: vec!["wsb".into(), "checkpoint".into()],
        pid,
        host: host_name(),
        taken_at: "2026-10-07T07:00:00Z".into(),
        reason: "checkpoint is writing into the tree".into(),
        protects: "the workspace".into(),
    };
    let dir = root.join(".wsb").join(WRITERS);
    std::fs::create_dir_all(&dir).unwrap();
    let file = dir.join(format!("{}-{pid}.json", host_name()));
    std::fs::write(&file, serde_json::to_string(&record).unwrap()).unwrap();
    file
}

/// TEST12588: a snapshot waits for a writer already under way, and takes the
/// tree once it has finished; a writer whose process is gone is not waited for.
///
/// The lock only stopped writers from STARTING. A test snapshot taken in the
/// middle of a checkpoint copied a tree half taken back from the local render,
/// and the run failed building it.
#[test]
fn test12588_a_snapshot_waits_for_a_writer_under_way() {
    let root = workspace();
    // A pid that is not running: a short-lived child's, reaped.
    let mut child = crate::git_backend::command_without_repo_scope(std::env::current_exe().unwrap())
        .arg("--list")
        .stdout(std::process::Stdio::null())
        .spawn()
        .unwrap();
    let dead = child.id();
    child.wait().unwrap();
    let gone = writing_as(root.path(), dead);
    let writing = writing_as(root.path(), alive());
    let finish = std::thread::spawn(move || {
        std::thread::sleep(Duration::from_millis(2500));
        std::fs::remove_file(writing).unwrap();
    });
    let started = Instant::now();
    let held = take_within(root.path(), "wsb test", "snapshotting the tree", Duration::from_secs(60))
        .expect("the snapshot is taken once the writer is done");
    assert!(
        started.elapsed() >= Duration::from_secs(2),
        "the snapshot was taken while a writer was still writing"
    );
    finish.join().unwrap();
    assert!(!gone.exists(), "a dead writer's registration was left behind");
    drop(held);
}

/// TEST12589: a writer is registered for as long as its guard lives; one a
/// registered writer started is covered by its parent and registers nothing.
///
/// A checkpoint runs renders and commit hooks that are wsb too. Had they
/// registered and waited themselves, a snapshot waiting for the checkpoint
/// would have waited on a child that waited on the snapshot.
#[test]
fn test12589_a_writer_registers_for_its_run_and_its_children_do_not() {
    let _env = crate::test_environment::Environment::set(&[(WRITING_ENV, ""), (IGNORE_ENV, "")]);
    let root = workspace();
    let dir = root.path().join(".wsb").join(WRITERS);
    let writing = begin_writing(root.path(), "checkpoint").expect("registered").expect("a writer of its own");
    assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "the writer is not registered");
    assert!(std::env::var_os(WRITING_ENV).is_some(), "its children would not know they are covered");
    assert!(begin_writing(root.path(), "update").expect("asked").is_none(), "a child registered beside its parent");
    drop(writing);
    assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "the registration outlived the writer");
}