From 5de096a22170e47c7b28a4c0b595e680535d53eb Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Fri, 25 Sep 2026 13:04:15 -0700 Subject: [PATCH] v2.2 plan: route templates (opencode-*) and multiple wake broadcast addresses; given tests Co-Authored-By: Claude Fable 5.1 --- docs/plans/v2.2/01-route-templates.md | 78 ++++++++++++ docs/plans/v2.2/02-broadcasts.md | 68 +++++++++++ docs/plans/v2.2/README.md | 30 +++++ .../internal/admin/admin_template_test.go | 92 ++++++++++++++ .../_files/internal/config/config_v22_test.go | 115 ++++++++++++++++++ .../_files/internal/proxy/template_test.go | 85 +++++++++++++ .../_files/internal/wake/broadcasts_test.go | 73 +++++++++++ 7 files changed, 541 insertions(+) create mode 100644 docs/plans/v2.2/01-route-templates.md create mode 100644 docs/plans/v2.2/02-broadcasts.md create mode 100644 docs/plans/v2.2/README.md create mode 100644 docs/plans/v2.2/_files/internal/admin/admin_template_test.go create mode 100644 docs/plans/v2.2/_files/internal/config/config_v22_test.go create mode 100644 docs/plans/v2.2/_files/internal/proxy/template_test.go create mode 100644 docs/plans/v2.2/_files/internal/wake/broadcasts_test.go diff --git a/docs/plans/v2.2/01-route-templates.md b/docs/plans/v2.2/01-route-templates.md new file mode 100644 index 0000000..54d677c --- /dev/null +++ b/docs/plans/v2.2/01-route-templates.md @@ -0,0 +1,78 @@ +# v2.2 task 01: route templates + +**Branch:** `v2.2` (`git switch -c v2.2 master` if it does not exist, else `git switch v2.2`; `git status --short` must be empty, otherwise stop) +**Commit subject:** `Route templates: a route named x-* serves any request route x-` + +## Goal + +`PLAN.md` §4a gives every OpenCode instance its own route (`CROSSBAR_ROUTE="$(basename "$PWD")-$$"`), +but the config only knows explicit `[routes.NAME]` tables and everything else is `404 unknown +route`. After this task a route whose name ends in `-*` is a **template**: a request route that +starts with the part before the star, with something non-empty after it, uses that route's hosts, +default model and peers. Leases and accounting stay keyed by the concrete route name, so two +instances never share a lease and each has its own usage row. + +## Files + +- Copy: `internal/config/config_v22_test.go` (its `TestWakeBroadcasts` belongs to task 02 and + will fail to compile until then — see step 2), `internal/proxy/template_test.go`, + `internal/admin/admin_template_test.go` +- Modify: `internal/config/config.go`, `internal/proxy/proxy.go`, `internal/admin/admin.go`, + `internal/admin/admin_ops.go`, `cmd/crossbar/main.go` (the identity middleware's route→peers + lookup), `docs/implementer-log.md` + +## Interfaces + +```go +package config + +// Route resolves a request route name: an exact entry wins; else the longest template +// "-*" whose prefix (including the dash) starts name with a non-empty remainder; +// else ok is false. key is the config key that matched (the template's name for a template). +// A name that is not a valid route name (the pattern below) or contains '*' never matches. +func (c *Config) Route(name string) (r Route, key string, ok bool) +``` + +Rules: +1. Config route keys match `^[a-z0-9][a-z0-9-]*$` (as before) **or** `^[a-z0-9][a-z0-9-]*-\*$` + (a template). Anything else with a `*` is `routes.: must match …` as today. A + template alone satisfies "at least one route". +2. Resolution order: exact, then longest matching template, then none. +3. The proxy resolves both the path form and the `X-Crossbar-Route` header form through + `cfg.Route`; the concrete name (not the template key) is the route used for leases, + accounting rows, logs and headers. The "conflicting route" check compares concrete names. +4. Admin: `POST /_crossbar/routes/{route}` resolves through `cfg.Route` — a concrete route under + a template can be pinned/released even before its first request; the template name itself + is `404 unknown route`. `GET /_crossbar/routes` lists config keys (templates under their own + name) and, for a template, the leases of every concrete route it matches. +5. `main.go`: the identity middleware's `func(route string) ([]string, bool)` uses `cfg.Route`. + +## Steps + +- [ ] **1.** Branch as above; copy the three given tests. +- [ ] **2. See them fail.** `config_v22_test.go` also references `Wake.Addresses()` (task 02); until + then run the config package with `-run 'TestRouteTemplate'` **after** adding a temporary + stub? No — do not add stubs. Instead implement task 01 and run + `go test -race -count=1 ./internal/proxy/ ./internal/admin/` for the behaviour, and `go vet + ./internal/config/` will fail only on the missing `Addresses` method until task 02: that is + expected and is the one allowed red at the end of this task. Say so in the log row. +- [ ] **3.** `config.go`: the template regex, `Route()`. **4.** `proxy.go` route resolution. + **5.** `admin.go` / `admin_ops.go`. **6.** `main.go`. +- [ ] **7.** `gofmt -w`; `go test -race -count=1 ./internal/proxy/ ./internal/admin/ ./internal/health/ ./internal/wake/` → `ok`. +- [ ] **8.** Row `v2.2/01-route-templates`; commit (the gate runs green after task 02). + +```sh +git add internal/config internal/proxy internal/admin cmd/crossbar docs/implementer-log.md +git commit +``` + +## Done when + +- `TestRouteTemplateServesConcreteRoutes` and `TestRoutesViewAndPinWithTemplates` pass under + `-race`; every earlier proxy/admin test still passes; given files byte-identical; no file over + 400 lines. `internal/config` is red only on `Addresses` (task 02). + +## Stop and report if + +- Passing needs a change to any earlier given test. +- The 400-line limit forces `proxy.go` apart: say which split you propose before doing it. diff --git a/docs/plans/v2.2/02-broadcasts.md b/docs/plans/v2.2/02-broadcasts.md new file mode 100644 index 0000000..9d9685a --- /dev/null +++ b/docs/plans/v2.2/02-broadcasts.md @@ -0,0 +1,68 @@ +# v2.2 task 02: several broadcast addresses per wake target + +**Branch:** `v2.2` (`git switch v2.2`; `git status --short` must be empty, otherwise stop) +**Commit subject:** `Wake: a target may list several broadcast addresses` + +## Goal + +Titan roams between two Wi-Fi networks; hyperborea sits on both segments. A wake target can +therefore name **several** broadcast addresses and the magic packet goes to all of them. Config +keeps `broadcast = "host:port"` (one) and adds `broadcasts = ["host:port", …]` (a list); +exactly one of the two must be present. + +## Files + +- Copy: `internal/wake/broadcasts_test.go` (`internal/config/config_v22_test.go` was copied in + task 01 and its `TestWakeBroadcasts` becomes green here) +- Modify: `internal/config/config.go`, `internal/wake/wake.go`, `cmd/crossbar/main.go`, + `example.toml` (show the list form, commented), `docs/implementer-log.md` + +## Interfaces + +```go +package config +type Wake struct { + MAC string `toml:"mac"` + Broadcast string `toml:"broadcast"` + Broadcasts []string `toml:"broadcasts"` + Wait Duration `toml:"wait"` +} +// Addresses is Broadcast (when set) followed by Broadcasts: the list to send to, never empty +// for a parsed config. +func (w *Wake) Addresses() []string + +package wake +type Target struct { + MAC string + Broadcast string // one address, as before + Broadcasts []string // more addresses; Send goes to Broadcast (if set) and then each of these + Wait time.Duration +} +``` + +Rules: +1. Validation (`hosts..wake…` fields): `broadcast` and `broadcasts` both set → error on + `hosts..wake.broadcasts`; neither, or an empty list → error on `hosts..wake.broadcast`; + every entry must be `host:port` (same check as `broadcast` today) → error on + `hosts..wake.broadcasts`. +2. `Waker.Wake` sends one packet to every address in order. An address that fails to resolve + or send is logged (or ignored) and does **not** stop the remaining addresses; `Wake` returns + false only if *no* address could be sent to (or on the existing timeout/ctx rules). +3. `main.go` fills `Target.Broadcasts` from `Wake.Addresses()`. + +## Steps + +- [ ] **1.** `git switch v2.2`; copy `broadcasts_test.go`. +- [ ] **2. See it fail** (compile). **3.** `config.go`, then `wake.go`, then `main.go`, `example.toml`. +- [ ] **4.** `go test -race -count=3 ./internal/wake/ ./internal/config/` → `ok`. **5.** `go test -race -count=1 ./...`; `make smoke`. +- [ ] **6.** `make gate`. **7.** Row `v2.2/02-broadcasts`; commit. + +```sh +git add internal/config internal/wake cmd/crossbar example.toml docs/implementer-log.md +git commit +``` + +## Done when + +- All given tests pass; the v2 `wake_test.go` and `config_v2_test.go` are untouched and green; + gate and smoke ok; given files byte-identical. diff --git a/docs/plans/v2.2/README.md b/docs/plans/v2.2/README.md new file mode 100644 index 0000000..9be35b5 --- /dev/null +++ b/docs/plans/v2.2/README.md @@ -0,0 +1,30 @@ +# v2.2 implementation plan: what the hyperborea deploy exposed + +> **For the implementing model:** do not work from this file. The owner gives you one task file at +> a time. This file is the index for the owner and the reviewer. + +**Goal:** two small gaps found on 2026-09-25 when crossbar went live on hyperborea. + +- **01-route-templates** — `PLAN.md`'s one-route-per-OpenCode-instance launcher produces route + names the config has never seen, and unknown routes are 404. A route named `opencode-*` now + serves every `opencode-`; leases and accounting stay per concrete route. Given: + `config/config_v22_test.go`, `proxy/template_test.go`, `admin/admin_template_test.go`. +- **02-broadcasts** — titan roams between two Wi-Fi networks and hyperborea is on both segments, + so a wake target needs more than one broadcast address. Given: `wake/broadcasts_test.go` + (+ `TestWakeBroadcasts` in the config test above). + +**Order matters:** the config given test covers both tasks, so `internal/config` is red on one +method between task 01's commit and task 02's. Task 01's text says so; the gate runs after 02. + +**How this plan was made:** acceptance tests first, no reference implementation; the given tests +compiled against a panic-only skeleton of the new names and failed on the v2.1 tree for the +intended reasons. + +## Global constraints + +- Everything in `AGENTS.md`. Branch `v2.2` from `master`. One task, one fresh OpenCode session, + one commit. Given files are copied and never edited; earlier plans' given files stay protected. + +## Changes during the run + +(none yet) diff --git a/docs/plans/v2.2/_files/internal/admin/admin_template_test.go b/docs/plans/v2.2/_files/internal/admin/admin_template_test.go new file mode 100644 index 0000000..8fb5561 --- /dev/null +++ b/docs/plans/v2.2/_files/internal/admin/admin_template_test.go @@ -0,0 +1,92 @@ +package admin_test + +import ( + "encoding/json" + "path/filepath" + "strings" + "testing" + "time" + + "git.wntrmute.dev/kyle/crossbar/internal/admin" + "git.wntrmute.dev/kyle/crossbar/internal/config" + "git.wntrmute.dev/kyle/crossbar/internal/health" + "git.wntrmute.dev/kyle/crossbar/internal/lease" + "git.wntrmute.dev/kyle/crossbar/internal/limiter" + "git.wntrmute.dev/kyle/crossbar/internal/store" +) + +// The routes view lists a template once, under its own name, with the leases of every concrete +// route it matched. A concrete route can be pinned; the template itself cannot. +func TestRoutesViewAndPinWithTemplates(t *testing.T) { + cfg, err := config.Parse(strings.NewReader(` +listen = "127.0.0.1:1" +[hosts.alpha] +base_url = "http://alpha:1" +models = { "m" = { parallel = 2 } } +[routes."opencode-*"] +hosts = ["alpha"] +default_model = "m" +`)) + if err != nil { + t.Fatal(err) + } + st, err := store.Open(filepath.Join(t.TempDir(), "x.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + hosts := &fakeHosts{ + st: map[string]health.Status{"alpha": {Healthy: true, Loaded: []string{"m"}}}, + draining: map[string]bool{}, + } + lt, err := lease.New(st, hosts, hosts, 30*time.Minute) + if err != nil { + t.Fatal(err) + } + if _, _, err := lt.Acquire(lease.Key{Route: "opencode-projecta-4242", FP: "fp1", Model: "m"}, []string{"alpha"}, time.Now()); err != nil { + t.Fatal(err) + } + if _, _, err := lt.Acquire(lease.Key{Route: "opencode-projectb-7", FP: "fp2", Model: "m"}, []string{"alpha"}, time.Now()); err != nil { + t.Fatal(err) + } + lim := limiter.New() + lim.Configure("alpha", "m", 2, 8) + r := &rig{h: admin.Handler(cfg, hosts, lt, lim, st, hosts), store: st, leases: lt, hosts: hosts} + + rec := r.do(t, "GET", "/_crossbar/routes", "") + if rec.Code != 200 { + t.Fatalf("%d %s", rec.Code, rec.Body.String()) + } + var out map[string]admin.RouteView + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + v, ok := out["opencode-*"] + if !ok || len(out) != 1 { + t.Fatalf("routes view keys = %v, want exactly the template", keysOf(out)) + } + if len(v.Hosts) != 1 || v.Hosts[0] != "alpha" || v.DefaultModel != "m" || len(v.Leases) != 2 { + t.Errorf("template view = %+v, want hosts [alpha], model m and the two concrete routes' leases", v) + } + + rec = r.do(t, "POST", "/_crossbar/routes/opencode-projecta-4242", `{"host":"alpha","pin":true}`) + if rec.Code != 200 { + t.Errorf("pin of a concrete templated route: %d %s, want 200", rec.Code, rec.Body.String()) + } + rec = r.do(t, "POST", "/_crossbar/routes/opencode-*", `{"host":"alpha","pin":true}`) + if rec.Code != 404 { + t.Errorf("pin of the template itself: %d, want 404 unknown route", rec.Code) + } + rec = r.do(t, "POST", "/_crossbar/routes/opencode-nothing-yet", `{"host":"alpha","pin":true}`) + if rec.Code != 200 { + t.Errorf("pin of a not-yet-seen concrete route under a template: %d %s, want 200 (it is a valid route)", rec.Code, rec.Body.String()) + } +} + +func keysOf(m map[string]admin.RouteView) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} diff --git a/docs/plans/v2.2/_files/internal/config/config_v22_test.go b/docs/plans/v2.2/_files/internal/config/config_v22_test.go new file mode 100644 index 0000000..9becd6a --- /dev/null +++ b/docs/plans/v2.2/_files/internal/config/config_v22_test.go @@ -0,0 +1,115 @@ +package config_test + +import ( + "strings" + "testing" + + "git.wntrmute.dev/kyle/crossbar/internal/config" +) + +const templateBase = ` +listen = "127.0.0.1:1" +[hosts.a] +base_url = "http://a:1" +models = { "m" = { }, "n" = { } } +[routes."opencode-*"] +hosts = ["a"] +default_model = "m" +[routes."opencode-rust-*"] +hosts = ["a"] +default_model = "n" +[routes.opencode-fixed] +hosts = ["a"] +[routes.paper] +hosts = ["a"] +` + +// A route whose name ends in "-*" is a template: any request route that starts with the part +// before the star, with something after it, uses that route's config. An exact name wins over a +// template; the longest matching template wins over shorter ones. +func TestRouteTemplatesResolve(t *testing.T) { + c, err := config.Parse(strings.NewReader(templateBase)) + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name, wantKey, wantModel string + ok bool + }{ + {"paper", "paper", "", true}, + {"opencode-fixed", "opencode-fixed", "", true}, // exact beats template + {"opencode-projecta-4242", "opencode-*", "m", true}, // template + {"opencode-rust-a-7", "opencode-rust-*", "n", true}, // longest template wins + {"opencode-", "", "", false}, // nothing after the prefix + {"opencode", "", "", false}, // the dash is part of the prefix + {"opencodex", "", "", false}, // not a prefix match + {"opencode-*", "", "", false}, // a literal star is never a request route + {"Opencode-A", "", "", false}, // not a valid route name + {"nope", "", "", false}, + } { + r, key, ok := c.Route(tc.name) + if ok != tc.ok || key != tc.wantKey || (ok && r.DefaultModel != tc.wantModel) { + t.Errorf("Route(%q) = (%+v, %q, %v), want key %q model %q ok %v", tc.name, r, key, ok, tc.wantKey, tc.wantModel, tc.ok) + } + } +} + +func TestRouteTemplateNamesAreValidated(t *testing.T) { + for name, tc := range map[string]struct { + route string + wantErr string + }{ + "star in the middle": {`"open*code"`, "routes.open*code"}, + "star without dash": {`"opencode*"`, "routes.opencode*"}, + "bare star": {`"*"`, "routes.*"}, + "double star": {`"opencode-**"`, "routes.opencode-**"}, + } { + t.Run(name, func(t *testing.T) { + text := "listen = \"127.0.0.1:1\"\n[hosts.a]\nbase_url = \"http://a:1\"\nmodels = { \"m\" = { } }\n[routes." + tc.route + "]\nhosts = [\"a\"]\n" + _, err := config.Parse(strings.NewReader(text)) + ce, ok := err.(*config.Error) + if !ok || ce.Field != tc.wantErr { + t.Fatalf("err = %v, want *config.Error on %q", err, tc.wantErr) + } + }) + } + // A template alone satisfies "at least one route". + if _, err := config.Parse(strings.NewReader("listen = \"127.0.0.1:1\"\n[hosts.a]\nbase_url = \"http://a:1\"\nmodels = { \"m\" = { } }\n[routes.\"x-*\"]\nhosts = [\"a\"]\n")); err != nil { + t.Errorf("a template-only config must parse: %v", err) + } +} + +// broadcasts: a wake target may name several broadcast addresses (a host that roams between two +// Wi-Fi networks). `broadcast` (one) and `broadcasts` (a list) are alternatives: exactly one. +func TestWakeBroadcasts(t *testing.T) { + head := "listen = \"127.0.0.1:1\"\n[hosts.a]\nbase_url = \"http://a:1\"\nmodels = { \"m\" = { } }\n[hosts.a.wake]\nmac = \"aa:bb:cc:dd:ee:ff\"\n" + tail := "\n[routes.r]\nhosts = [\"a\"]\n" + c, err := config.Parse(strings.NewReader(head + `broadcasts = ["192.168.88.255:9", "192.168.1.255:9"]` + tail)) + if err != nil { + t.Fatal(err) + } + if got := c.Hosts["a"].Wake.Addresses(); len(got) != 2 || got[0] != "192.168.88.255:9" || got[1] != "192.168.1.255:9" { + t.Errorf("Addresses() = %v, want both, in order", got) + } + c, err = config.Parse(strings.NewReader(head + `broadcast = "192.168.88.255:9"` + tail)) + if err != nil { + t.Fatal(err) + } + if got := c.Hosts["a"].Wake.Addresses(); len(got) != 1 || got[0] != "192.168.88.255:9" { + t.Errorf("Addresses() = %v, want the single broadcast", got) + } + for name, body := range map[string]string{ + "both": "broadcast = \"192.168.88.255:9\"\nbroadcasts = [\"192.168.1.255:9\"]", + "neither": "wait = \"30s\"", + "empty list": "broadcasts = []", + "bad entry": "broadcasts = [\"192.168.1.255\"]", // no port + } { + t.Run(name, func(t *testing.T) { + _, err := config.Parse(strings.NewReader(head + body + tail)) + ce, ok := err.(*config.Error) + if !ok || !strings.HasPrefix(ce.Field, "hosts.a.wake") { + t.Fatalf("err = %v, want *config.Error under hosts.a.wake", err) + } + }) + } +} diff --git a/docs/plans/v2.2/_files/internal/proxy/template_test.go b/docs/plans/v2.2/_files/internal/proxy/template_test.go new file mode 100644 index 0000000..2374f24 --- /dev/null +++ b/docs/plans/v2.2/_files/internal/proxy/template_test.go @@ -0,0 +1,85 @@ +package proxy_test + +import ( + "net/http" + "testing" + "time" + + "git.wntrmute.dev/kyle/crossbar/internal/proxy" + "git.wntrmute.dev/kyle/crossbar/internal/store" +) + +const templateHosts = ` +listen = "127.0.0.1:1" +[hosts.alpha] +base_url = %q +models = { "shared" = { parallel = 4 } } +[routes."opencode-*"] +hosts = ["alpha"] +default_model = "shared" +[routes.opencode-fixed] +hosts = ["alpha"] +default_model = "shared" +` + +// One OpenCode instance per route, without listing every instance in the config: a route named +// "opencode-*" serves any request route "opencode-". Leases and accounting are keyed +// by the concrete route name, so two instances never share a lease and each gets its own usage +// row. The literal template name is never a request route. +func TestRouteTemplateServesConcreteRoutes(t *testing.T) { + alpha := newUpstream(t, "alpha") + r := newRig(t, templateHosts, alpha) + + resp := r.post("/opencode-projecta-4242/v1/chat/completions", conversation(1, 1)) + drain(resp) + if resp.StatusCode != 200 || resp.Header.Get(proxy.HostHeader) != "alpha" || resp.Header.Get(proxy.LeaseHeader) != "new" { + t.Fatalf("first turn on a templated route: %d %q %q, want 200 alpha new", resp.StatusCode, resp.Header.Get(proxy.HostHeader), resp.Header.Get(proxy.LeaseHeader)) + } + resp = r.post("/opencode-projecta-4242/v1/chat/completions", conversation(1, 2)) + drain(resp) + if resp.Header.Get(proxy.LeaseHeader) != "reused" { + t.Errorf("second turn should reuse the lease, got %q", resp.Header.Get(proxy.LeaseHeader)) + } + // A second instance with the same conversation shape is a different route: its own lease. + resp = r.post("/opencode-projectb-7/v1/chat/completions", conversation(1, 1)) + drain(resp) + if resp.StatusCode != 200 || resp.Header.Get(proxy.LeaseHeader) != "new" { + t.Errorf("another instance must get its own lease: %d %q", resp.StatusCode, resp.Header.Get(proxy.LeaseHeader)) + } + // The header form resolves templates too. + resp = r.post("/v1/chat/completions", conversation(2, 1), proxy.RouteHeader, "opencode-projectc-1") + drain(resp) + if resp.StatusCode != 200 { + t.Errorf("X-Crossbar-Route with a templated name: %d, want 200", resp.StatusCode) + } + // An exact route still works and is not shadowed by the template. + resp = r.post("/opencode-fixed/v1/chat/completions", conversation(3, 1)) + drain(resp) + if resp.StatusCode != 200 { + t.Errorf("exact route: %d, want 200", resp.StatusCode) + } + + for _, path := range []string{"/opencode-*/v1/models", "/opencode-/v1/models", "/opencode/v1/models", "/opencodex/v1/models"} { + req, _ := http.NewRequest(http.MethodGet, r.front.URL+path, nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + drain(resp) + if resp.StatusCode != 404 { + t.Errorf("%s: %d, want 404 unknown route", path, resp.StatusCode) + } + } + + rows, _ := r.store.Usage(time.Time{}, store.ByRoute) + keys := map[string]int64{} + for _, row := range rows { + keys[row.Key] = row.Requests + } + if keys["opencode-projecta-4242"] != 2 || keys["opencode-projectb-7"] != 1 || keys["opencode-projectc-1"] != 1 || keys["opencode-fixed"] != 1 { + t.Errorf("usage by route = %v, want rows per concrete route", keys) + } + if _, present := keys["opencode-*"]; present { + t.Errorf("the template name must never be an accounting key: %v", keys) + } +} diff --git a/docs/plans/v2.2/_files/internal/wake/broadcasts_test.go b/docs/plans/v2.2/_files/internal/wake/broadcasts_test.go new file mode 100644 index 0000000..238ca53 --- /dev/null +++ b/docs/plans/v2.2/_files/internal/wake/broadcasts_test.go @@ -0,0 +1,73 @@ +package wake_test + +import ( + "net" + "testing" + "time" + + "git.wntrmute.dev/kyle/crossbar/internal/wake" +) + +// listener returns a UDP socket on 127.0.0.1 and a channel that gets one value per datagram. +func listener(t *testing.T) (string, <-chan []byte) { + t.Helper() + pc, err := net.ListenPacket("udp4", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { pc.Close() }) + got := make(chan []byte, 4) + go func() { + buf := make([]byte, 256) + for { + n, _, err := pc.ReadFrom(buf) + if err != nil { + return + } + b := make([]byte, n) + copy(b, buf[:n]) + got <- b + } + }() + return pc.LocalAddr().String(), got +} + +func expectPacket(t *testing.T, name string, got <-chan []byte) { + t.Helper() + select { + case b := <-got: + if len(b) != 102 { + t.Errorf("%s: got %d bytes, want a 102-byte magic packet", name, len(b)) + } + case <-time.After(2 * time.Second): + t.Errorf("%s: no packet within two seconds", name) + } +} + +// A target may name several broadcast addresses (a host that roams between two networks): the +// packet goes to every one of them, and one address that cannot be resolved does not stop the +// others. +func TestWakeSendsToEveryBroadcast(t *testing.T) { + a, gotA := listener(t) + b, gotB := listener(t) + h := &fakeHealth{after: 1 << 30} // never healthy + w := wake.New(map[string]wake.Target{"titan": {MAC: "aa:bb:cc:dd:ee:ff", Broadcasts: []string{a, "256.1.1.1:9", b}, Wait: 300 * time.Millisecond}}, h) + w.PollEvery(20 * time.Millisecond) + if w.Wake(t.Context(), "titan") { + t.Errorf("Wake must report false when the host never comes up") + } + expectPacket(t, "first address", gotA) + expectPacket(t, "third address, after an unresolvable second", gotB) +} + +// The single-address form keeps working, alone or together with the list. +func TestWakeBroadcastAndBroadcastsCombine(t *testing.T) { + a, gotA := listener(t) + b, gotB := listener(t) + h := &fakeHealth{after: 1 << 30} // never healthy + w := wake.New(map[string]wake.Target{"titan": {MAC: "aa:bb:cc:dd:ee:ff", Broadcast: a, Broadcasts: []string{b}, Wait: 300 * time.Millisecond}}, h) + w.PollEvery(20 * time.Millisecond) + w.Wake(t.Context(), "titan") + expectPacket(t, "Broadcast", gotA) + expectPacket(t, "Broadcasts[0]", gotB) +}