jdx/mise · error

brew-cask: temporary artifact directory was replaced

Error message

brew-cask: temporary artifact directory was replaced

What it means

Thrown while deleting the private staging directory mise creates inside the Caskroom to stage cask artifacts. The code holds an open directory descriptor and fstats it, then fstats the directory currently reachable at the staging name (AT_SYMLINK_NOFOLLOW). If the (st_dev, st_ino) pairs differ, the directory at that path was replaced after it was opened, so the unlinkat is refused instead of deleting whatever now lives at that name.

Source

Thrown at src/system/packages/brew/cask.rs:2047

        nix::unistd::unlinkat(parent, name, nix::unistd::UnlinkatFlags::NoRemoveDir)?;
    }
    Ok(())
}

#[cfg(unix)]
fn remove_private_staging_dir(
    parent: &TrustedOperationParent,
    staging: &TrustedOperationParent,
    staging_name: &std::ffi::OsStr,
) -> Result<()> {
    let bound = nix::sys::stat::fstat(&staging.fd)?;
    let linked = nix::sys::stat::fstatat(
        &parent.fd,
        staging_name,
        nix::fcntl::AtFlags::AT_SYMLINK_NOFOLLOW,
    )?;
    if bound.st_dev != linked.st_dev || bound.st_ino != linked.st_ino {
        bail!("brew-cask: temporary artifact directory was replaced");
    }
    nix::unistd::unlinkat(
        &parent.fd,
        staging_name,
        nix::unistd::UnlinkatFlags::RemoveDir,
    )?;
    Ok(())
}

fn validate_generic_copy_target(target: &Path) -> Result<()> {
    let prefix = prefix::prefix();
    if !target.starts_with(&prefix)
        || target.strip_prefix(&prefix)?.components().next().is_none()
        || !path_starts_with_resolved_root(target, &prefix)
    {
        bail!(
            "brew-cask: refusing generic artifact copy outside Homebrew prefix: {}",
            target.display()

View on GitHub (pinned to 9dcfcaa0dc)

Solutions

  1. Make sure no other mise/brew cask process is running against the same Caskroom, then retry the command
  2. Inspect the staging parent under the Caskroom, delete the leftover/replaced staging directory manually, and re-run
  3. Disable third-party cleanup or antivirus tools that touch the Caskroom during installs
  4. If it reproduces with nothing else running, capture the staging path and report it: it can indicate a local security issue or an exotic filesystem
Defensive patterns

Strategy: retry

Validate before calling

use std::os::unix::io::AsRawFd;
use nix::fcntl::{flock, FlockArg};

// Serialize cask operations per Caskroom so no second process can swap staging dirs
fn lock_caskroom(caskroom: &std::path::Path) -> std::fs::File {
    let lock = std::fs::OpenOptions::new()
        .create(true).append(true)
        .open(caskroom.join(".mise-cask.lock")).expect("open lock");
    flock(lock.as_raw_fd(), FlockArg::LockExclusive).expect("lock caskroom");
    lock // keep alive for the whole install/uninstall
}

Try / catch

match run_cask_op(&cask) {
    Err(e) if e.to_string().contains("temporary artifact directory was replaced") => {
        // transient race: wait for the concurrent operation, then retry once
        std::thread::sleep(std::time::Duration::from_secs(2));
        run_cask_op(&cask)?
    }
    other => other?,
}

Prevention

When it happens

Trigger: A cask install/uninstall run in which another process removes and recreates the staging directory (or swaps a symlink in its place) between staging-dir creation and cleanup: a second mise/brew cask process on the same Caskroom, a temp-file cleaner, or a deliberate symlink-swap race this check exists to catch.

Common situations: Two concurrent cask operations in one Caskroom; cleanup/'optimizer' tools purging staging trees mid-install; a previously crashed run leaving a different directory at the staging name; Caskroom on a network filesystem where inode identity is unstable.

Related errors


AI-assisted analysis of jdx/mise@9dcfcaa0dc (2026-08-17). Data as JSON: /api/errors/d19801dff1899b4c. Report an issue: GitHub.