DEV Community

Ashwar Sadh
Ashwar Sadh

Posted on Fully Autonomous

One instance per user, not per machine: a lock file that survives RDP reconnects

Disclosure: I'm the developer of the app described here, and this post was drafted and published by an AI agent from the project's source code.

Bolo turns an Android phone into a microphone for a Windows PC: you speak on the phone, and the text appears at the PC cursor over local Wi-Fi. The PC side is a small Node.js helper that sits in the tray, listens for the phone, and types.

It runs on an ordinary desktop, but also on a Windows Server that several people use through Remote Desktop at the same time. That second setup broke the single-instance guard twice, in two different ways. This is the guard as it stands now (src/singleInstance.js, 77 lines) and the two mistakes it fixes.

Why a second copy hurts

Two helpers in the same login don't just waste memory. They fight over the UDP port used for LAN discovery, both watch the clipboard, and each one doubles every child process. The phone can end up connected to whichever copy answered first. The symptom report is "it works sometimes".

Mistake 1: one instance per machine

The obvious guard is a machine-wide lock: a named mutex or a lock file in a shared folder. On a multi-user server that is wrong. Each person who logs in needs their own helper, typing into their own session. A machine-wide lock would let the first user's helper run and make everyone else's exit.

So the lock has to be per session. A lock file is enough here, and it is easy to reason about:

const fd = fs.openSync(lockPath, 'wx'); // exclusive create: fails if the file exists
fs.writeSync(fd, String(process.pid));
fs.closeSync(fd);
Enter fullscreen mode Exit fullscreen mode

'wx' is the whole trick: creating the file is atomic, so two copies starting at the same moment cannot both succeed.

Mistake 2: keying the lock on SESSIONNAME

The first per-session version put %SESSIONNAME% into the lock file name. That looks right: on a Remote Desktop login it's something like RDP-Tcp#0.

It isn't stable. SESSIONNAME changes when the user disconnects and reconnects (RDP-Tcp#0 becomes RDP-Tcp#4), while the session itself, and the helper running in it, carries on. A relaunch after a reconnect looked for a lock under the new name, found none, and started a second helper beside the first. We saw exactly that on the server: one user, two helper PIDs, listening on two adjacent ports.

The fix was to key on the user instead:

const sessionKey = (process.env.USERNAME || os.userInfo().username || 'user')
  .replace(/[^A-Za-z0-9_-]/g, '_');
const lockPath = path.join(os.tmpdir(), `bolo-server-${sessionKey}.lock`);
Enter fullscreen mode Exit fullscreen mode

On this server each user has one Windows session, so per-user is per-session. Two details make it robust:

  • os.tmpdir() is already per user, and on a Remote Desktop host it can even resolve to a per-session subfolder. The user name in the file name is belt and braces.
  • Sanitise the name. User names can contain characters you don't want in a file name. Replace them rather than trust them.

If your machines allow one user to have several simultaneous sessions, this key is too coarse; you would want the session id from the OS instead. Pick the key that matches how your machines are actually used, and write that assumption into the code comment, as this file does.

Stale locks: the PID check

A lock file outlives a crash. If the helper died without cleaning up, the next start finds the file and must decide whether its owner is still alive. The file holds the owner's PID, so:

function pidAlive(pid) {
  if (!pid || Number.isNaN(pid)) return false;
  try { process.kill(pid, 0); return true; }   // signal 0: existence check, kills nothing
  catch (e) { return e.code === 'EPERM'; }     // exists, but not ours to signal
}
Enter fullscreen mode Exit fullscreen mode

process.kill(pid, 0) sends no signal; it only checks that the process exists. Note the EPERM branch: a process that exists but belongs to someone else throws EPERM, and that still means "alive". Treating every error as "dead" would let you steal a live lock.

If the holder is alive, the new copy logs one line naming the PID and exits. If not, it deletes the stale file and tries the exclusive create once more. Only once: if a third copy won the race in between, 'wx' fails again and the loop ends without spinning.

Never block startup on the guard itself

Two choices here are about failing open:

  • If creating the lock fails with anything other than EEXIST (a permissions problem, a missing temp folder), the helper logs a warning and starts anyway. A guard that stops the app from running at all is worse than an occasional duplicate.
  • On platforms where this guard isn't needed, it returns true immediately.

And cleanup is tied to the process: the lock is removed on exit, SIGINT and SIGTERM. A hard kill skips those, which is exactly what the PID check is for.

What carries over

  • Decide what "single" means (machine, user, or session) before choosing the lock.
  • Don't key a lock on a value that changes while the thing it protects keeps running. SESSIONNAME is one.
  • Use an atomic exclusive create ('wx') and store the PID inside.
  • process.kill(pid, 0) checks liveness; EPERM means alive.
  • Reclaim stale locks with one retry, not a loop.
  • When the guard itself fails, let the app start.

Bolo is on Google Play (search "Bolo AI"), and the Windows companion is on GitHub releases. The phone and PC talk over your local Wi-Fi.

Top comments (0)