Node.jsfile lockingGPUClaude CodeAI agents自動化

Several Claude sessions, one GPU: a cross-process render lock in a single file

· 7 min read
Table of Contents
  1. Decide the granularity first: lock one video, not a whole run
  2. Atomic create: `openSync(path, 'wx')`
  3. Busy: wait, don't skip
  4. Heartbeat: so a long worker isn't mistaken for a dead one
  5. Clearing a stale lock: rename first, then confirm it's the same one
  6. Release: only your own lock, on every exit path
  7. After it shipped
  8. When not to use this

Our video pipeline has one GPU: an RTX 3080 with 20 GB of memory. Local voice, image generation and rendering all use it.

Until October 12 three kinds of programs wanted that card: the Shorts autopilot, the newly built channel autopilot, and workers sent off by various Claude sessions to run experiments by hand. The Shorts autopilot had its own in-process queue, so heavy jobs inside that program waited their turn, but it had no say over other programs. Once the channel autopilot went live, both sides could each start a render in the same second; if GPU memory ran short, both could fail.

That night we wrote a cross-process render lock: 183 lines, no external packages.

Decide the granularity first: lock one video, not a whole run

The easy option is to hold the lock for an autopilot's entire run. But a channel worker has a 100-minute timeout per video and a Shorts run covers several videos. Lock a whole run and the other side waits for hours.

So the unit is "one video's heavy work":

  • Shorts autopilot: each heavy job (image generation, render, re-record plus re-render) takes the lock separately.
  • Channel autopilot: the parent process takes it per topic and releases when the render worker exits. Review only looks at images, doesn't touch the GPU, and doesn't take the lock.

The two sides take turns, and neither starves.

Atomic create: `openSync(path, 'wx')`

Windows has no flock. The simplest atomic operation in Node is opening with 'wx': if the file exists it throws EEXIST. Check and create are a single step, so two programs can't both decide "no lock" and both create one.

function tryCreate(p, owner, slug) {
  const token = crypto.randomBytes(8).toString('hex')
  let fd
  try { fd = fs.openSync(p, 'wx') } catch (e) { if (e.code === 'EEXIST') return null; throw e }
  try {
    fs.writeSync(fd, JSON.stringify({ pid: process.pid, acquiredAt: new Date().toISOString(), owner, slug, token }))
  } finally { fs.closeSync(fd) }
  return token
}

The lock file records the pid, when it was taken, who took it (reels or channels), which video, and a random token. The token is what makes "only delete your own lock" possible later.

Busy: wait, don't skip

When the lock is taken there are two options: skip this video, or wait.

Skipping means the video just isn't there this week, which shows up as one extra line in a weekly report that's easy to miss. We wrote a whole post on silent failures, and "the program is alive but did nothing" is the hardest kind to catch. So we wait: retry every 20 seconds, and every 5 minutes log "waiting for render lock, held by X since HH:MM" so anyone reading the log can see who is blocking.

The wait has a cap too, 150 minutes by default. On timeout, a Shorts video is dropped, and a channel video plus everything after it is marked deferred.

Heartbeat: so a long worker isn't mistaken for a dead one

One test for a stale lock is "the lock file hasn't changed in a long time." But a channel worker can legitimately hold the lock for up to 100 minutes; judge by acquisition time alone and someone else will clear it.

The fix is a heartbeat: while holding the lock, call utimesSync every 5 minutes to update the file's modification time. Staleness is judged on modification time, not acquisition time:

  • no heartbeat for more than 90 minutes, or
  • the pid in the lock file no longer exists.

Remember to unref() the heartbeat timer, or it will keep the process alive after its work is done.

Check the pid with process.kill(pid, 0): it sends no signal, it only checks. ESRCH means gone; EPERM means the process exists but you lack permission, and must be treated as alive, or you will clear someone else's lock.

Clearing a stale lock: rename first, then confirm it's the same one

The step most likely to go wrong is clearing a stale lock. The scenario: A decides the lock is dead and is about to delete it. In that gap the original lock is released normally and B takes a fresh one. If A deletes now, it deletes B's new lock.

So don't delete directly. Rename it out of the way, read what you moved, and confirm the token and pid match what you judged stale:

function removeStale(p, judged, log) {
  const tmp = `${p}.stale-${process.pid}-${crypto.randomBytes(3).toString('hex')}`
  try { fs.renameSync(p, tmp) } catch { return false }
  let got = null
  try { got = JSON.parse(fs.readFileSync(tmp, 'utf8')) } catch {}
  const same = judged.info && got && got.token === judged.info.token && got.pid === judged.info.pid
  if (!same) { /* grabbed someone's brand-new lock: put it back */ ... return false }
  fs.unlinkSync(tmp)
  log(`render lock stale, cleared: ${judged.why}`)
  return true
}

If you moved the wrong one, put it back. The real code also handles a lock file that was just created and hasn't had its contents written yet, counting "unreadable" as part of the judgment.

Release: only your own lock, on every exit path

On release, read the lock file first and delete it only if both pid and token match. That way, even if a process is late and its lock has already been cleared as stale and taken by someone else, it won't delete the wrong one.

There are several exits: an explicit release(), normal process exit, an exception, Ctrl+C (SIGINT), SIGTERM. All of them hang off process.on('exit') and two signal handlers. The only one that never reaches a hook is a forced kill like taskkill /F, and that one is left to the next process to clear via the missing pid.

Last, a small tool: render-lock.cjs status prints who holds the lock and how long ago the heartbeat was; clear removes only stale locks and needs --force if the holder is alive. Running status before any manual render is the rule we give every Claude session.

After it shipped

The lock was committed at 20:18 on the night of 10/12, with 9 tests that all run in a temp directory and never touch the real lock file.

The same night, on the first real channel run, the first video was sent back in review round one. When it went to re-render, another worker was finishing up and holding the lock; the re-render waited 9 minutes in the queue and then got the lock. It was not skipped.

The next morning three things wanted the GPU at once: a voice experiment, channel rendering, and a blind model test. The voice experiment's worker noticed the CLI only had status and clear, with no "take the lock and run a command," so it wrapped the same acquire() itself; both times it got the lock in 0 minutes, landing between the channel renders. The blind test's renders used withLock one at a time and waited a total of 14 minutes.

Three kinds of programs, different Claude sessions, and every time it was a queue and a wait. Nothing was skipped.

When not to use this

This lock fits "one machine, a few programs, each holding it for minutes to an hour or two." Across machines you want a real coordination service; if you need to acquire many times per second, a queue that retries every 20 seconds is far too slow. Our case is one GPU and holds of minutes to an hour or two, and a single file is enough.

Earlier in this series: the token ledger for agent-produced videos and three cost experiments.


What we open-source follows the same line: small, zero dependencies, readable in one sitting. ultraprobe is a zero-dependency CLI and SDK that checks a system prompt against 25 defense vectors; you can also paste one into UltraProbe on the web.

FAQ

How do you build a cross-process mutex in Node.js on Windows?

Create a lock file with fs.openSync(path, 'wx'). The 'wx' flag fails if the file already exists, so check and create are one atomic step and two processes can't both succeed. If you don't get it, retry after a delay. The winner writes its pid and a random token into the file, and on release deletes the file only if those still match. Windows has no flock, and this needs no dependencies.

If the process holding the lock crashes, how does the lock get released?

Two tests: the pid in the lock file no longer exists, or the lock file has had no heartbeat for 90 minutes (the holder touches the file's modification time every 5 minutes). Either one means stale. The lock file is first renamed out of the way, the renamed file is checked to be the one that was judged stale, and only then is the lock taken again. Normal exit, exceptions and Ctrl+C all release through an exit hook; a force-killed process never runs its hook, so the next process clears it via the missing pid.

When the lock is busy, should a job skip or wait?

We chose to wait. Skipping makes that video silently disappear for the week; waiting only makes it later. The wait is capped (150 minutes by default), after which the video is marked deferred, and the log prints the current holder every 5 minutes so a human can see who is blocking.

Weekly AI Automation Playbook

No fluff — just templates, SOPs, and technical breakdowns you can use right away.

Join the Solo Lab Community

Free resource packs, daily build logs, and AI agents you can talk to. A community for solo devs who build with AI.

Want to try it yourself?

UltraProbe is free and needs no sign-up. One scan tells you whether Google and AI engines can find your site.