Add the wake package: magic packets and a waiter

Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
2026-09-25 10:03:07 -07:00
parent 2fe3b9c865
commit bddf6c93f2
3 changed files with 257 additions and 0 deletions
+142
View File
@@ -0,0 +1,142 @@
// Package wake sends wake-on-LAN magic packets and waits for a sleeping host to
// appear healthy in the health table. A sleeping host takes tens of seconds to
// come up, so the Waker remembers when it last sent and wakes a host at most once
// per wait window.
package wake
import (
"context"
"fmt"
"net"
"sync"
"time"
)
// Target describes how to wake one named host.
type Target struct {
MAC, Broadcast string // MAC "aa:bb:cc:dd:ee:ff" (any separator, any case); Broadcast "host:port"
Wait time.Duration
}
// Health reports whether a named host is currently healthy. Implementations must
// be safe for concurrent use.
type Health interface{ Healthy(name string) bool }
// magicPacketLen is six sync bytes plus the MAC repeated sixteen times.
const magicPacketLen = 6 + 6*16
// MagicPacket builds a wake-on-LAN magic packet: six 0xff bytes followed by the
// target MAC sixteen times, a 102-byte frame.
func MagicPacket(mac string) ([]byte, error) {
m, err := net.ParseMAC(mac)
if err != nil {
return nil, fmt.Errorf("wake: parse MAC %q: %w", mac, err)
}
if len(m) != 6 {
return nil, fmt.Errorf("wake: MAC %q is not six bytes", mac)
}
pkt := make([]byte, magicPacketLen)
for i := range pkt[:6] {
pkt[i] = 0xff
}
for i := 0; i < 16; i++ {
copy(pkt[6+i*6:], m)
}
return pkt, nil
}
// Send emits one magic packet for mac to the broadcast address as a single UDP4
// datagram, reporting parse, resolve and write errors.
func Send(mac, broadcast string) error {
pkt, err := MagicPacket(mac)
if err != nil {
return err
}
remote, err := net.ResolveUDPAddr("udp4", broadcast)
if err != nil {
return fmt.Errorf("wake: resolve broadcast %q: %w", broadcast, err)
}
conn, err := net.DialUDP("udp4", nil, remote)
if err != nil {
return fmt.Errorf("wake: dial broadcast %q: %w", broadcast, err)
}
defer conn.Close()
if _, err := conn.Write(pkt); err != nil {
return fmt.Errorf("wake: write packet to %q: %w", broadcast, err)
}
return nil
}
// Waker wakes named hosts at most once per wait window and waits for the health
// table to report them healthy. It is safe for concurrent Wake calls.
type Waker struct {
mu sync.Mutex
targets map[string]Target
health Health
lastSent map[string]time.Time
poll time.Duration
}
// New returns a Waker for the given targets, polling health every second.
func New(targets map[string]Target, h Health) *Waker {
return &Waker{
targets: targets,
health: h,
lastSent: make(map[string]time.Time),
poll: time.Second,
}
}
// PollEvery sets how often Wake re-checks health; it is a test hook. Production
// keeps the 1 s default from New.
func (w *Waker) PollEvery(d time.Duration) {
w.mu.Lock()
w.poll = d
w.mu.Unlock()
}
// Wake sends a magic packet for host if none was sent in the last Wait, then
// polls health until the host is healthy, the wait elapses, or ctx is done. It
// returns true only when the host becomes healthy, and false for an unknown
// host, on timeout, or when ctx ends first.
func (w *Waker) Wake(ctx context.Context, host string) bool {
w.mu.Lock()
target, ok := w.targets[host]
if !ok {
w.mu.Unlock()
return false
}
now := time.Now()
if last, sent := w.lastSent[host]; !sent || now.Sub(last) >= target.Wait {
w.lastSent[host] = now
w.mu.Unlock()
if err := Send(target.MAC, target.Broadcast); err != nil {
return false
}
w.mu.Lock()
}
poll := w.poll
deadline := now.Add(target.Wait)
w.mu.Unlock()
timer := time.NewTimer(poll)
defer timer.Stop()
for {
if w.health.Healthy(host) {
return true
}
if time.Now().After(deadline) {
return false
}
d := poll
if rem := time.Until(deadline); rem < d {
d = rem
}
timer.Reset(d)
select {
case <-ctx.Done():
return false
case <-timer.C:
}
}
}