wsb-daemon 0.258.6441

wsb's daemon: the workbench, the GitHub mirror's schedule and peer continuity, for every workspace on the machine
// Building the frontend from a copy of its sources, and publishing the built
// directory by rename.
//
// Shared by `build.rs` — which `include!`s this file, because a build script
// is a separate compilation unit and cannot depend on the crate it builds —
// and by the crate's own tests, which is the point: the sequence below is the
// only thing standing between an interrupted frontend build and a tree that
// cannot be compiled at all, and a sequence nothing can execute is a sequence
// nothing can test.
//
// `frontend/dist` is an INPUT. `#[derive(RustEmbed)]` reads it while the
// crate's macros expand, so it has to exist whenever anything compiles this
// crate — not merely afterwards. Deleting it and then spending twenty seconds
// building into it made an ordinary pair of commands (a `cargo install`
// beside a `cargo build`) destroy each other, and made a failed npm build
// leave a tree whose NEXT build failed differently and more confusingly than
// the one that actually broke.
//
// Only `std`, and every path fully qualified: the file is spliced into
// whatever module includes it, and an import here would collide with one
// already there.

/// Move `staging` to `live`, keeping whatever was at `live` until it succeeds.
///
/// `frontend/dist` is an INPUT: `#[derive(RustEmbed)]` reads it while this
/// crate's macros expand, so it has to exist whenever anything compiles the
/// crate — not merely afterwards.
///
/// The gap during which `live` does not exist is between two renames rather
/// than across a whole build — microseconds instead of tens of seconds. It
/// cannot be closed entirely: POSIX `rename` refuses to replace a non-empty
/// directory, so the old one must be moved aside first.
///
/// If the second rename fails the first is undone, because a tree left with no
/// `live` at all is exactly the state this exists to prevent.
pub fn publish_by_rename(staging: &std::path::Path, live: &std::path::Path, previous: &std::path::Path) -> Result<(), String> {
    if !staging.is_dir() {
        return Err(format!(
            "{} is not a directory, so there is nothing to publish",
            staging.display()
        ));
    }
    if previous.exists() {
        std::fs::remove_dir_all(previous)
            .map_err(|e| format!("removing {}: {e}", previous.display()))?;
    }

    let moved_aside = live.exists();
    if moved_aside {
        std::fs::rename(live, previous).map_err(|e| {
            format!("moving {} aside to {}: {e}", live.display(), previous.display())
        })?;
    }

    if let Err(e) = std::fs::rename(staging, live) {
        // Put it back. Leaving the previous build at a name nothing looks for
        // is the same outcome as deleting it.
        if moved_aside {
            std::fs::rename(previous, live).map_err(|restore| {
                format!(
                    "publishing {} as {} failed ({e}), and restoring the previous \
                     build from {} failed too ({restore}) — {} now has no build",
                    staging.display(),
                    live.display(),
                    previous.display(),
                    live.display()
                )
            })?;
        }
        return Err(format!(
            "publishing {} as {}: {e}",
            staging.display(),
            live.display()
        ));
    }

    if moved_aside {
        std::fs::remove_dir_all(previous)
            .map_err(|e| format!("removing the superseded {}: {e}", previous.display()))?;
    }
    Ok(())
}

/// Copy the named entries of `from` into `to`, each replacing whatever a
/// previous copy left at its name, and leave everything else in `to` alone.
///
/// The frontend is built from such a copy in the build script's own output
/// directory, never where its sources are. A build script may write nowhere
/// else: built from a published package, the sources sit in cargo's registry,
/// shared by every project that uses that version, and `cargo publish` refuses
/// a package whose build adds anything beside them. `npm ci` added all of
/// `node_modules`.
///
/// Each entry is replaced whole, so a file deleted from the sources does not
/// live on in the copy and get built. What is not named — the installed
/// `node_modules` — stays, so a build whose lockfile has not changed does not
/// install again.
pub fn copy_sources(from: &std::path::Path, to: &std::path::Path, entries: &[&str]) -> Result<(), String> {
    std::fs::create_dir_all(to).map_err(|e| format!("creating {}: {e}", to.display()))?;
    for entry in entries {
        let source = from.join(entry);
        let target = to.join(entry);
        let kind = std::fs::metadata(&source).map_err(|e| format!("{}: {e}", source.display()))?;
        match std::fs::symlink_metadata(&target) {
            Ok(was) if was.is_dir() => std::fs::remove_dir_all(&target),
            Ok(_) => std::fs::remove_file(&target),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
            Err(e) => Err(e),
        }
        .map_err(|e| format!("replacing {}: {e}", target.display()))?;
        if kind.is_dir() {
            copy_tree(&source, &target)?;
        } else {
            std::fs::copy(&source, &target)
                .map_err(|e| format!("copying {} to {}: {e}", source.display(), target.display()))?;
        }
    }
    Ok(())
}

fn copy_tree(from: &std::path::Path, to: &std::path::Path) -> Result<(), String> {
    std::fs::create_dir_all(to).map_err(|e| format!("creating {}: {e}", to.display()))?;
    let entries = std::fs::read_dir(from).map_err(|e| format!("reading {}: {e}", from.display()))?;
    for entry in entries {
        let entry = entry.map_err(|e| format!("reading {}: {e}", from.display()))?;
        let source = entry.path();
        let target = to.join(entry.file_name());
        if std::fs::metadata(&source).map_err(|e| format!("{}: {e}", source.display()))?.is_dir() {
            copy_tree(&source, &target)?;
        } else {
            std::fs::copy(&source, &target)
                .map_err(|e| format!("copying {} to {}: {e}", source.display(), target.display()))?;
        }
    }
    Ok(())
}

/// Clear anything a previous run left at the scratch names.
///
/// Called BEFORE a build rather than after one: a run that was killed leaves
/// them behind, and a build that started from a half-populated scratch tree
/// would publish it.
pub fn clear_scratch(staging: &std::path::Path, previous: &std::path::Path) -> Result<(), String> {
    for leftover in [staging, previous] {
        if leftover.exists() {
            std::fs::remove_dir_all(leftover)
                .map_err(|e| format!("removing {}: {e}", leftover.display()))?;
        }
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    fn tree(root: &std::path::Path, name: &str, marker: &str) -> std::path::PathBuf {
        let dir = root.join(name);
        std::fs::create_dir_all(dir.join("assets")).expect("the directory");
        std::fs::write(dir.join("index.html"), marker).expect("index.html");
        std::fs::write(dir.join("assets").join("app.js"), marker).expect("an asset");
        dir
    }

    fn marker_at(dir: &std::path::Path) -> String {
        std::fs::read_to_string(dir.join("index.html")).expect("index.html is readable")
    }

    /// TEST9774: publishing replaces the live tree completely, leaving no scratch.
    ///
    /// The baseline, and it pins the thing the old code got wrong by deleting
    /// first: nothing from the previous build may survive into the new one.
    /// Vite's own `emptyOutDir` clears only files it knows about, which is why
    /// `dist` used to be removed outright.
    #[test]
    fn test9774_publishing_replaces_the_live_tree_and_leaves_no_scratch() {
        let root = tempfile::tempdir().expect("a scratch directory");
        let live = tree(root.path(), "dist", "the previous build");
        // A file only the OLD build had. It must not survive.
        std::fs::write(live.join("stale.js"), "from a build nobody wants").expect("a stale file");
        let staging = tree(root.path(), "dist.building", "the new build");
        let previous = root.path().join("dist.previous");

        publish_by_rename(&staging, &live, &previous).expect("it publishes");

        assert_eq!(marker_at(&live), "the new build");
        assert!(
            !live.join("stale.js").exists(),
            "a file from the previous build survived into the published one"
        );
        assert!(!staging.exists(), "the scratch tree was left behind");
        assert!(!previous.exists(), "the superseded tree was left behind");
    }

    /// TEST9775: publishing into an empty tree works.
    ///
    /// The first build on a fresh clone, where there is nothing to move aside.
    /// A sequence that assumed a previous build would fail here — and this is
    /// the case every new machine hits first.
    #[test]
    fn test9775_the_first_build_publishes_with_nothing_to_move_aside() {
        let root = tempfile::tempdir().expect("a scratch directory");
        let live = root.path().join("dist");
        let staging = tree(root.path(), "dist.building", "the first build");
        let previous = root.path().join("dist.previous");

        publish_by_rename(&staging, &live, &previous).expect("it publishes");

        assert_eq!(marker_at(&live), "the first build");
        assert!(!staging.exists());
        assert!(!previous.exists());
    }

    /// TEST9776: a build that produced nothing cannot destroy the live tree.
    ///
    /// This is the failure that started it. `frontend/dist` is read by
    /// `#[derive(RustEmbed)]` while the crate's macros expand, so a tree with
    /// no `dist` does not merely lack a frontend — it CANNOT BE COMPILED, and
    /// the error names an embed macro rather than the npm build that failed.
    #[test]
    fn test9776_a_failed_build_leaves_the_live_tree_untouched() {
        let root = tempfile::tempdir().expect("a scratch directory");
        let live = tree(root.path(), "dist", "the good build");
        let previous = root.path().join("dist.previous");
        // What an npm build that died leaves: no scratch tree at all.
        let staging = root.path().join("dist.building");

        let error = publish_by_rename(&staging, &live, &previous)
            .expect_err("there is nothing to publish");
        assert!(
            error.contains("nothing to publish"),
            "the refusal does not say what is wrong: {error}"
        );

        assert!(live.is_dir(), "the live tree was removed by a failed publish");
        assert_eq!(
            marker_at(&live),
            "the good build",
            "the live tree was replaced by a build that never happened"
        );
    }

    /// TEST9777: a publish that fails half way puts the previous build back.
    ///
    /// Between the two renames the live path does not exist. If the second
    /// fails and the first is not undone, the tree is left with no `dist` at
    /// all — the same unbuildable state, reached from the other direction.
    ///
    /// Provoked for real: the live path is made a location the rename cannot
    /// succeed into, by putting a FILE where the directory has to go.
    #[cfg(unix)]
    #[test]
    fn test9777_a_publish_that_fails_restores_the_previous_build() {
        let root = tempfile::tempdir().expect("a scratch directory");
        let live = tree(root.path(), "dist", "the good build");
        let staging = tree(root.path(), "dist.building", "the new build");
        let previous = root.path().join("dist.previous");

        // The parent is made unwritable AFTER the first rename would have to
        // happen, which cannot be arranged from outside — so instead the
        // scratch tree is removed between the two steps by giving `previous`
        // a path that is already a file. `remove_dir_all` on it fails, and the
        // publish stops before it has moved anything.
        std::fs::write(&previous, "not a directory").expect("a file in the way");

        let error = publish_by_rename(&staging, &live, &previous)
            .expect_err("it cannot use a file as the previous-build directory");

        assert!(live.is_dir(), "the live tree was lost: {error}");
        assert_eq!(
            marker_at(&live),
            "the good build",
            "the live tree was damaged by a publish that failed"
        );
        assert!(staging.is_dir(), "the new build was lost as well");
    }

    /// TEST20050: the copy the frontend is built from follows its sources and
    /// keeps what was installed.
    ///
    /// A file deleted from the sources must not survive in the copy, or the
    /// build ships a component nobody has any more. The installed
    /// `node_modules` must survive, or every build installs again.
    #[test]
    fn test20050_the_build_copy_follows_the_sources_and_keeps_the_install() {
        let root = tempfile::tempdir().expect("a scratch directory");
        let sources = root.path().join("frontend");
        std::fs::create_dir_all(sources.join("src/lib")).expect("sources");
        std::fs::write(sources.join("package.json"), "{}").expect("package.json");
        std::fs::write(sources.join("src/main.ts"), "first").expect("main.ts");
        std::fs::write(sources.join("src/lib/gone.ts"), "removed later").expect("gone.ts");
        let copy = root.path().join("out/workbench/frontend");

        copy_sources(&sources, &copy, &["package.json", "src"]).expect("the first copy");
        std::fs::create_dir_all(copy.join("node_modules/vite")).expect("an install");
        std::fs::write(copy.join("node_modules/vite/package.json"), "{}").expect("an installed file");

        std::fs::remove_file(sources.join("src/lib/gone.ts")).expect("a source is deleted");
        std::fs::write(sources.join("src/main.ts"), "second").expect("a source is edited");
        copy_sources(&sources, &copy, &["package.json", "src"]).expect("the second copy");

        assert_eq!(std::fs::read_to_string(copy.join("src/main.ts")).expect("main.ts"), "second");
        assert!(!copy.join("src/lib/gone.ts").exists(), "a deleted source lived on in the build copy");
        assert!(
            copy.join("node_modules/vite/package.json").is_file(),
            "the install was thrown away by a copy that did not name it"
        );
    }

    /// TEST9778: leftovers from a killed run are cleared before the next build.
    ///
    /// A run that was interrupted leaves a half-populated scratch tree. Built
    /// on top of, it would publish a mixture of two builds — which is worse
    /// than either, and invisible.
    #[test]
    fn test9778_a_killed_runs_leftovers_are_cleared_before_the_next_build() {
        let root = tempfile::tempdir().expect("a scratch directory");
        let staging = tree(root.path(), "dist.building", "half of an interrupted build");
        let previous = tree(root.path(), "dist.previous", "a tree nobody cleaned up");

        clear_scratch(&staging, &previous).expect("the leftovers are cleared");

        assert!(!staging.exists(), "an interrupted build's scratch survived");
        assert!(!previous.exists(), "an abandoned previous tree survived");
    }
}