fix(remote): bind the wake socket before enabling broadcast

setBroadcast() on an unbound dgram socket throws EBADF on Linux and the following
send fails with EACCES, so the magic packet silently never left the machine — the
feature reported a wake that never happened. Caught by waking a real sleeping host
(a unit test with a real UDP broadcast would not be welcome in CI, so the socket is
injectable and the bind-before-setBroadcast ORDER is asserted).
This commit is contained in:
Randalix
2026-09-15 14:24:13 +02:00
parent 8b5a13435a
commit 2f61be6e74
2 changed files with 88 additions and 18 deletions
+38 -18
View File
@@ -574,10 +574,14 @@ export function runRemoteWakeCommand(command: string, timeoutMs = REMOTE_WAKE_CO
* interface to broadcast on, permission) rather than throwing — a broken network
* must not break the wake flow, which reports the failure itself.
*/
export function sendWakePackets(addresses: number[][], port = 9): Promise<boolean> {
export function sendWakePackets(
addresses: number[][],
port = 9,
createSocket: WakeSocketFactory = () => dgram.createSocket('udp4')
): Promise<boolean> {
if (addresses.length === 0) return Promise.resolve(false);
return new Promise((resolve) => {
const socket = dgram.createSocket('udp4');
const socket = createSocket();
let settled = false;
const finish = (value: boolean) => {
if (settled) return;
@@ -590,25 +594,41 @@ export function sendWakePackets(addresses: number[][], port = 9): Promise<boolea
resolve(value);
};
socket.once('error', () => finish(false));
try {
socket.setBroadcast(true);
} catch {
finish(false);
return;
}
let pending = addresses.length;
let failed = false;
for (const mac of addresses) {
const packet = buildMagicPacket(mac);
socket.send(packet, port, '255.255.255.255', (err) => {
if (err) failed = true;
pending--;
if (pending === 0) finish(!failed);
});
}
// ⚠️ `setBroadcast` BEFORE the socket is bound fails with EBADF on Linux, and the
// send that follows fails with EACCES — i.e. the packet silently never leaves the
// machine. So the broadcast flag is set in the bind callback, always. (Found by
// the live test: macOS/BSD tolerate the wrong order, Linux does not.)
socket.bind(() => {
try {
socket.setBroadcast(true);
} catch {
finish(false);
return;
}
let pending = addresses.length;
let failed = false;
for (const mac of addresses) {
socket.send(buildMagicPacket(mac), port, '255.255.255.255', (err?: Error | null) => {
if (err) failed = true;
pending--;
if (pending === 0) finish(!failed);
});
}
});
});
}
/** The `dgram` surface {@link sendWakePackets} uses — injectable so the bind/setBroadcast ORDER is testable. */
export interface WakeSocket {
bind(callback: () => void): void;
setBroadcast(flag: boolean): void;
send(msg: Buffer, port: number, address: string, callback: (err?: Error | null) => void): void;
close(): void;
once(event: 'error', listener: (err: Error) => void): void;
}
export type WakeSocketFactory = () => WakeSocket;
/** Poll the host until it accepts connections again, or the bound is hit. */
export async function waitUntilRemoteReady(
remote: WakeableRemote,
+50
View File
@@ -23,6 +23,7 @@ import {
decideRemoteInputAction,
parseMacList,
resolveWakeTarget,
sendWakePackets,
wakeConfigured,
REMOTE_WAKE_PENDING_MAX_BYTES,
type RemoteWakeDeps,
@@ -111,6 +112,55 @@ describe('MAC parsing + magic packet', () => {
}
});
it('binds BEFORE enabling broadcast — the order that silently kills the packet on Linux', async () => {
// `setBroadcast()` on an unbound socket throws EBADF on Linux and the follow-up
// send dies with EACCES, so the magic packet never leaves the machine (verified
// against a real sleeping host). The order is asserted, not described.
const calls: string[] = [];
const sent: { packet: Buffer; port: number; address: string }[] = [];
const packets = await sendWakePackets(
[
[4, 217, 245, 128, 198, 88],
[28, 97, 180, 32, 88, 235],
],
9,
() => ({
bind: (cb: () => void) => {
calls.push('bind');
cb();
},
setBroadcast: () => calls.push('setBroadcast'),
send: (packet: Buffer, port: number, address: string, cb: (err?: Error | null) => void) => {
calls.push('send');
sent.push({ packet, port, address });
cb(null);
},
close: () => calls.push('close'),
once: () => undefined,
})
);
expect(packets).toBe(true);
expect(calls[0]).toBe('bind');
expect(calls[1]).toBe('setBroadcast');
// One 102-byte magic packet per MAC, to the broadcast address on port 9.
expect(sent).toHaveLength(2);
expect(sent.every((s) => s.packet.length === 102 && s.port === 9 && s.address === '255.255.255.255')).toBe(true);
});
it('reports failure when the platform refuses to broadcast', async () => {
const ok = await sendWakePackets([[4, 217, 245, 128, 198, 88]], 9, () => ({
bind: (cb: () => void) => cb(),
setBroadcast: () => {
throw new Error('EBADF');
},
send: () => undefined,
close: () => undefined,
once: () => undefined,
}));
expect(ok).toBe(false);
});
it('resolves the wake target with the command as the explicit override', () => {
const mac = '04:d9:f5:80:c6:58';
expect(resolveWakeTarget(undefined)).toBeNull();