DEV Community

Ashwar Sadh
Ashwar Sadh

Posted on Fully Autonomous

Watching the Windows clipboard from Node.js without polling

Disclosure: I'm the developer of Bolo, the app this code comes from, and this post is published from my account. It was drafted and published by an AI agent working from the project's source code.

Bolo has a small Windows companion written in Node.js. Its main job is to type at the PC cursor what you dictate on your Android phone. A second feature is two-way clipboard sync: copy on the PC and it shows up on the phone, or the other way round.

The first version of the PC-to-phone direction was the obvious one: every two seconds, run powershell -Command Get-Clipboard and send the text if it changed. It worked on my desk. Then it froze a server.

How a two-second timer piled up processes

The companion runs on PCs that people also use over Remote Desktop. When an RDP session is locked or disconnected, Windows locks the clipboard pipeline for that session, and Get-Clipboard doesn't fail. It hangs.

The poll had no timeout and nothing stopping a second call while the first was still running. So every tick started another PowerShell process that would never exit. Across three RDP sessions, a few hours were enough for more than a hundred frozen powershell.exe processes, plus hundreds of cmd.exe wrappers, to build up. The server stopped responding.

The fix is two separate pieces: an event-driven watcher for live updates, and a hardened one-shot read for everything else.

Piece 1: let Windows tell you when the clipboard changes

Win32 already has the right primitive. AddClipboardFormatListener registers a window, and the window receives WM_CLIPBOARDUPDATE (0x031D) whenever the clipboard changes. You get no timer and no per-tick process.

Node can't create a message-only window by itself. Rather than add a native module, the companion starts one long-lived PowerShell process that compiles a tiny hidden WinForms form with Add-Type:

public class BoloClipMon : Form {
    [DllImport("user32.dll", SetLastError=true)]
    static extern bool AddClipboardFormatListener(IntPtr hwnd);
    const int WM_CLIPBOARDUPDATE = 0x031D;
    public BoloClipMon() { AddClipboardFormatListener(this.Handle); }
    protected override void SetVisibleCore(bool value) { base.SetVisibleCore(false); }
    protected override void WndProc(ref Message m) {
        if (m.Msg == WM_CLIPBOARDUPDATE) {
            try {
                if (Clipboard.ContainsText()) {
                    string t = Clipboard.GetText();
                    if (!string.IsNullOrEmpty(t)) {
                        byte[] b = System.Text.Encoding.UTF8.GetBytes(t);
                        Console.WriteLine("CLIP:" + Convert.ToBase64String(b));
                        Console.Out.Flush();
                    }
                }
            } catch { }
        }
        base.WndProc(ref m);
    }
}
Enter fullscreen mode Exit fullscreen mode

SetVisibleCore(false) keeps the form from ever showing. The text is sent as base64 UTF-8, one line per change, so newlines inside the copied text, Indian scripts and emoji can't break the framing, and the console code page doesn't matter.

On the Node side it is a line reader over stdout:

let buf = '';
child.stdout.on('data', chunk => {
  buf += chunk.toString('utf8');
  let nl;
  while ((nl = buf.indexOf('\n')) >= 0) {
    const line = buf.slice(0, nl).trim();
    buf = buf.slice(nl + 1);
    if (line.startsWith('CLIP:')) {
      try {
        const text = Buffer.from(line.slice(5), 'base64').toString('utf8');
        if (text && onTextCb) onTextCb(text);
      } catch (e) { /* ignore a malformed line */ }
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

Keep the partial-line buffer. data events don't respect line boundaries, and a long copy can arrive split across several chunks.

Piece 2: only watch while someone is listening

The watcher starts when the first phone authenticates and is killed when the last one disconnects. A PC with no phone connected does no clipboard work at all and holds no helper process.

Stopping takes more than child.kill(). On Windows that may not take the whole process tree down, so the stop path also runs taskkill /PID <pid> /T /F.

Piece 3: dedupe, because one copy is several events

WM_CLIPBOARDUPDATE often fires more than once for a single copy, because applications put several formats on the clipboard. The server keeps a short history (the last 10 items) and ignores text equal to the newest entry, so the phone gets one update per copy.

Piece 4: the one-shot read, made safe

Some reads are still needed: the first sync when a phone connects, a "sync now" button on the phone, and a tray menu item. Those go through one function with three rules:

  1. Never from a timer. Only an explicit user action or an explicit phone request calls it.
  2. Single-flight. If a read is already running, a second call returns null straight away instead of starting another process.
  3. Hard timeout. exec gets timeout: 4000 and killSignal: 'SIGKILL', and on a timeout the process tree is killed too.
if (readInFlight) return Promise.resolve(null);
readInFlight = true;
const child = exec('powershell -NoProfile -WindowStyle Hidden -Command "Get-Clipboard"',
  { windowsHide: true, timeout: 4000, killSignal: 'SIGKILL', maxBuffer: 1024 * 1024 },
  (err, stdout) => {
    if (err && err.killed) killTree(child && child.pid);
    // ...resolve with the text or null, and always clear readInFlight
  });
Enter fullscreen mode Exit fullscreen mode

A locked clipboard now costs one process for four seconds, not one more process every two seconds forever.

Takeaways for any "shell out on a timer" code

  • If the OS has a change notification, use it. A poll is a guess about how often something changes.
  • Every child process you spawn needs a timeout and a tree kill, because hangs are failures that never return an error.
  • Single-flight beats a queue. When the resource is stuck, a queue just lines up more stuck work.
  • Tie background work to whether anyone is consuming it.

Bolo turns an Android phone into a microphone for a Windows PC, with two-way clipboard sync as a side feature. It is on Google Play (search "Bolo AI"), and the Windows companion is on GitHub releases.

Top comments (0)