Building a Single-Process Mutex Command Runner for CLI Apps in Node/Bun
A deep-dive technical look at preventing race conditions, overlapping file operations, and clobbering in concurrent background-agent automations.
When building autonomous software agencies or multi-agent pipelines (like our own Slack-driven mobile command bot), you quickly run into a fundamental operational hazard: state collision.
If your background agents or cron hooks trigger file mutations, Git operations, or third-party deployments concurrently, they will inevitably clobber each other. A deployment command running while a Git commit is in progress will fail; two agents trying to write to the same configuration file simultaneously will corrupt the state.
To prevent this, we engineered a lightweight, single-process, file-system-backed Mutual Exclusion (Mutex) locker for Node and Bun CLI runtimes. This guarantees that critical operations run sequentially without blocking your primary process thread.
---
The Core Concept: File-System Lock Primitives
In distributed systems, you might reach for Redis Redlock. But for single-machine background CLI tools, Redis introduces unnecessary operational footprint and dependency bloat.
Instead, we can exploit atomic file-system operations. On Unix-like systems, creating a directory (fs.mkdir) or creating a file with exclusive write flags (wx on fs.open) are atomic operations at the OS kernel level. If two processes attempt to create the same lock directory at the exact same microsecond, only one will succeed, while the other receives an EEXIST error.
Here is how to build a robust Mutex class in TypeScript using Bun's fast native file primitives.
---
🛠️ TypeScript Implementation: Atomic Mutex Runner
Below is a self-contained Mutex implementation designed to safely queue tasks or fail gracefully if a lock is held.
import { mkdir, rmdir, stat } from "fs/promises";
import { join } from "path";
export class FileMutex {
private lockPath: string;
private maxAgeMs: number;
constructor(lockName: string, maxAgeMs = 300000) {
// We use a temporary system path for lock directories
this.lockPath = join("/tmp", ${lockName}.lock);
this.maxAgeMs = maxAgeMs; // 5-minute safety threshold
}
/
---
🚀 Running Commands Safely with the Mutex Wrapper
We can wrap our shell execution or file operations inside a simple helper function. If the lock is held, the command can either queue itself (using a retry delay) or exit gracefully with a warning to Slack.
async function runWithMutex(lockName: string, task: () => Promise) { const mutex = new FileMutex(lockName); console.log( 🔒 Acquiring mutex lock for [${lockName}]...); let retries = 5; while (retries > 0) { if (await mutex.acquire()) { console.log(✅ Lock acquired! Executing critical operations...); try { await task(); } finally { await mutex.release(); console.log(🔓 Lock released successfully.); } return; } else { retries--; console.warn(🛑 Lock held by another process. Retrying in 2 seconds... (${retries} retries left)); await Bun.sleep(2000); } } throw new Error(❌ Mutex Lock Timeout: Could not execute task [${lockName}]. Process blocked.); } // EXAMPLE USAGE: await runWithMutex("vercel-deploy-pipeline", async () => { // Safe from race conditions! console.log("Staging changes..."); await Bun.$git add . && git commit -m "chore: automated sync"; console.log("Pushing and deploying to production..."); await Bun.$git push && vercel --prod --yes; });
---
Key Takeaways
if (!exists) mkdir()). That introduces a Classic Time-of-Check to Time-of-Use (TOCTOU) race condition. Directly attempt to create it and catch the error.maxAgeMs on lock files. If your process crashes unexpectedly mid-execution, your locker should auto-purge the stale directory on the next run instead of locking up the system indefinitely.