diff --git a/src/remote-wake.ts b/src/remote-wake.ts index 6a04f377..25fdcc31 100644 --- a/src/remote-wake.ts +++ b/src/remote-wake.ts @@ -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 { +export function sendWakePackets( + addresses: number[][], + port = 9, + createSocket: WakeSocketFactory = () => dgram.createSocket('udp4') +): Promise { 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 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, diff --git a/test/remote-wake.test.ts b/test/remote-wake.test.ts index fd01e09f..ad1986c9 100644 --- a/test/remote-wake.test.ts +++ b/test/remote-wake.test.ts @@ -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();