DEV Community

Ashwar Sadh
Ashwar Sadh

Posted on Fully Autonomous

One UDP port, several Windows users: LAN discovery that works on a shared PC

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 turns an Android phone into a wireless microphone for a Windows PC: you speak on the phone and the text appears at the PC cursor. Before any of that, the phone has to find the PC on the Wi-Fi without anyone typing an IP address. This post is about the one setup where my first discovery design quietly failed, a Windows PC shared by several signed-in users, and the small file-based fix.

Two discovery paths, because one is not enough

The Windows helper advertises itself two ways:

  1. mDNS / DNS-SD as _bolo._tcp, with the PC name, user name and a stable serverId in the TXT record. The phone browses with Android's NsdManager.
  2. A UDP broadcast probe. Every two seconds the phone sends the 9-byte string BoloProbe to the subnet broadcast address on port 57433. Every helper that hears it replies with a small JSON record.

mDNS is the polite standard, but on real office and home networks it is the one that goes missing: some routers filter multicast between Wi-Fi clients, and a Windows firewall profile can swallow it. The broadcast probe is the fallback that usually gets through. The phone works out the directed broadcast address from the Wi-Fi DHCP info rather than using 255.255.255.255:

val dhcp = wifiManager.dhcpInfo
if (dhcp.ipAddress == 0) return InetAddress.getByName("255.255.255.255")
val broadcast = (dhcp.ipAddress and dhcp.netmask) or dhcp.netmask.inv()
Enter fullscreen mode Exit fullscreen mode

The failure: only one process can own the port

On a shared PC every signed-in user runs their own helper, and each helper tried to bind UDP 57433. Only one bind succeeds. The others got EADDRINUSE (or EACCES, depending on who bound first), logged it, and from then on never answered a probe. Those users' PCs showed up in the phone's nearby list only after a manual pairing. The symptom looked like "discovery is flaky for some people". The real cause was deterministic: one listener for several helpers.

The helper already created its socket with reuseAddr: true and exclusive: false. On that PC, with helpers running in separate Windows user sessions, the extra binds still failed. I stopped relying on socket options and changed the design instead.

The fix: one listener answers for everyone, from a shared folder

Instead of fighting over the port, each helper publishes its record to a machine-wide folder, and whichever helper owns the port answers for all of them.

Every helper writes a heartbeat file to %ProgramData%\Bolo\discovery\<serverId>.json every 15 seconds:

ownRecordFile = path.join(dir, `${record.serverId}.json`);
// Write in place (not rename): another user's process may hold the file open to read it.
fs.writeFileSync(ownRecordFile, JSON.stringify({ ...record, pid: process.pid, at: Date.now() }));
Enter fullscreen mode Exit fullscreen mode

%ProgramData% is the right home because every user can reach it. The comment matters too: the usual "write a temp file, then rename" atomic pattern fails on Windows when another process has the target open, and here another user's helper may be reading it at that moment. A plain overwrite plus a tolerant reader is simpler.

The port owner replies with its own record plus every fresh peer:

socket.on('message', (msg, rinfo) => {
  if (msg.toString() !== 'BoloProbe') return;
  const own = ownRecord(wsPort);
  for (const record of [own, ...peerRecords(own.serverId)]) {
    socket.send(JSON.stringify(record), rinfo.port, rinfo.address);
  }
});
Enter fullscreen mode Exit fullscreen mode

Freshness is by file age, not by trusting content. A helper that crashed can't delete its file, so the reader skips anything not modified in the last 45 seconds (three missed heartbeats), skips its own record, and skips files that are half-written or don't parse. It also strips pid and at before replying, so the phone sees the same shape whoever answers:

if (now - fs.statSync(file).mtimeMs > RECORD_FRESH_MS) continue;
const rec = JSON.parse(fs.readFileSync(file, 'utf8'));
if (!rec || !rec.bolo || !rec.serverId || rec.serverId === selfServerId) continue;
const { pid, at, ...reply } = rec;
Enter fullscreen mode Exit fullscreen mode

A helper that lost the bind keeps retrying. On error it closes the socket and schedules a re-bind in 15 seconds. When the owning user signs out and their helper exits, the next retry wins the port and takes over answering, with no election protocol.

socket.on('error', (err) => {
  // Usually EADDRINUSE/EACCES: another user's helper owns the port and answers for us.
  try { socket.close(); } catch (e) {}
  if (server === socket) server = null;
  scheduleRebind(wsPort);
});
Enter fullscreen mode Exit fullscreen mode

On a clean exit the helper deletes its own record, so the phone stops listing that user straight away instead of after 45 seconds.

A detail on the phone side: dedupe by identity

A PC can now reply through mDNS and through UDP, and several records can come back from one IP. The phone merges results by serverId (falling back to the name), not by address. One PC with four users shows as four entries; one user seen twice shows once.

Which address to offer first

Each record carries all of the PC's usable addresses, sorted so the likely-reachable one comes first: 192.168.x (home Wi-Fi), then 10.x, then 172.x, then anything else such as a VPN's 100.x. Virtual adapters (WSL, Hyper-V, VMware, VirtualBox) and link-local 169.254.x / fe80: addresses are dropped. A phone can't reach those addresses, so offering them first only adds timeouts.

Takeaways

  • If a port can have only one owner, don't make every process need it. Let one process answer on behalf of the rest.
  • A shared folder plus heartbeat files is a perfectly good registry on one machine. Judge liveness by modification time, and read tolerantly.
  • On Windows, prefer an in-place overwrite to rename-over when other processes may hold the file open.
  • Keep a broadcast fallback next to mDNS; multicast is the first thing a network drops.

Bolo is on Google Play (search "Bolo AI"), and the Windows companion is on GitHub releases. Discovery and typing work over your local Wi-Fi.

Top comments (0)