Code Farm: per-plot auto-replant pause and crop-lock check; community sync and node work
DevPlace CI / test (push) Failing after 44m45s

Continuous Delivery could never be turned off once a crop was planted. Add a
one-shot per-plot "stop after harvest" flag (POST /game/plot/stop, Devii tool
game_plot_stop, game_plots.stop_after_harvest), cleared by the next plant,
autoreplant claim or Refactor. Autoreplant now also respects crop locks through
the shared _crop_lock predicate. Plan and measurements in happyplanting.md.

Also includes the pending community sync, node network and related work in the
tree; the full suite passed (4999) with all of it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 02:23:43 +02:00
co-authored by Claude Sonnet 5.5
parent ce899fc5e1
commit 02ab747cd0
203 changed files with 11624 additions and 301 deletions
+3 -1
View File
@@ -30,7 +30,9 @@ devplace-node version
Create the device secret on your DevPlace profile after consenting to bandwidth sharing. `login` asks the server to accept the secret before storing it (`--no-verify` skips that). The secret can also come from `DEVPLACE_NODE_SECRET`. Every command takes `--config PATH`.
`run` is quiet: it logs start, disconnects, and one totals line per day. Add `-v` for detail. Exit code 3 means the server ended this device for good (revoked, suspended, kill switch, no consent, not adult, replaced by another instance of the same device); fix the cause and start it again.
`run` is quiet: it logs start, disconnects, and one totals line per day. Add `-v` for detail. Exit code 3 means the server ended this device for good (revoked, kill switch, no consent, not adult, replaced by another instance of the same device); fix the cause and start it again. A timed suspension is not final: on a ticket `403` or a `kick` with reason `suspended` the client waits until the `until` instant (at least 60 seconds, at most 24 hours, plus jitter; 5 minutes when `until` is empty or unreadable) and then asks for a new ticket.
When the server setting `pause_on_battery` is on, jobs are refused with `on_battery` while the machine runs on battery (Linux `/sys/class/power_supply`, macOS `pmset -g batt`, Windows `GetSystemPowerStatus`; the result is cached for 15 seconds). An unknown power state counts as mains power. The device label the server holds is logged at connect with `-v`.
## Config file
+53
View File
@@ -0,0 +1,53 @@
package main
import (
"strings"
"sync"
"time"
)
const batteryCacheSeconds = 15
type BatteryProbe func() (bool, error)
type batteryWatch struct {
mu sync.Mutex
probe BatteryProbe
now func() time.Time
debugf func(format string, args ...any)
checked time.Time
value bool
warned bool
}
func newBatteryWatch(probe BatteryProbe, now func() time.Time) *batteryWatch {
return &batteryWatch{probe: probe, now: now}
}
func (w *batteryWatch) OnBattery() bool {
w.mu.Lock()
defer w.mu.Unlock()
moment := w.now()
if !w.checked.IsZero() && moment.Sub(w.checked) < time.Duration(batteryCacheSeconds)*time.Second {
return w.value
}
w.checked = moment
value, err := w.probe()
if err != nil {
value = false
if !w.warned && w.debugf != nil {
w.debugf("battery state unknown, treating as mains power: %v", err)
}
w.warned = true
}
w.value = value
return value
}
func parsePmset(text string) bool {
return strings.Contains(text, "Now drawing from 'Battery Power'")
}
func windowsOnBattery(acLine, batteryFlag byte) bool {
return acLine == 0 && batteryFlag != 128 && batteryFlag != 255
}
+19
View File
@@ -0,0 +1,19 @@
//go:build darwin
package main
import (
"context"
"os/exec"
"time"
)
func systemBattery() (bool, error) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, "pmset", "-g", "batt").Output()
if err != nil {
return false, err
}
return parsePmset(string(out)), nil
}
+53
View File
@@ -0,0 +1,53 @@
//go:build linux
package main
import (
"os"
"path/filepath"
"strings"
)
const powerSupplyRoot = "/sys/class/power_supply"
func systemBattery() (bool, error) {
return sysfsOnBattery(powerSupplyRoot)
}
func readSupplyField(dir, name string) string {
data, err := os.ReadFile(filepath.Join(dir, name))
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}
func isMainsType(kind string) bool {
return kind == "Mains" || kind == "ADP" || strings.HasPrefix(kind, "USB")
}
func sysfsOnBattery(root string) (bool, error) {
entries, err := os.ReadDir(root)
if err != nil {
return false, err
}
discharging := false
for _, entry := range entries {
dir := filepath.Join(root, entry.Name())
if readSupplyField(dir, "scope") == "Device" {
continue
}
kind := readSupplyField(dir, "type")
switch {
case kind == "Battery":
if readSupplyField(dir, "status") == "Discharging" {
discharging = true
}
case isMainsType(kind):
if readSupplyField(dir, "online") == "1" {
return false, nil
}
}
}
return discharging, nil
}
+60
View File
@@ -0,0 +1,60 @@
//go:build linux
package main
import (
"os"
"path/filepath"
"testing"
)
type supply map[string]string
func buildTree(t *testing.T, supplies map[string]supply) string {
t.Helper()
root := t.TempDir()
for name, fields := range supplies {
dir := filepath.Join(root, name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
for key, value := range fields {
if err := os.WriteFile(filepath.Join(dir, key), []byte(value+"\n"), 0o644); err != nil {
t.Fatal(err)
}
}
}
return root
}
func TestSysfsOnBatteryTable(t *testing.T) {
cases := []struct {
name string
supplies map[string]supply
want bool
}{
{"desktop without battery", map[string]supply{"AC": {"type": "Mains", "online": "1"}}, false},
{"empty tree", map[string]supply{}, false},
{"laptop discharging", map[string]supply{"BAT0": {"type": "Battery", "status": "Discharging"}, "AC": {"type": "Mains", "online": "0"}}, true},
{"laptop charging", map[string]supply{"BAT0": {"type": "Battery", "status": "Charging"}, "AC": {"type": "Mains", "online": "1"}}, false},
{"laptop full on mains", map[string]supply{"BAT0": {"type": "Battery", "status": "Full"}, "AC": {"type": "Mains", "online": "1"}}, false},
{"discharging but usb-c online", map[string]supply{"BAT0": {"type": "Battery", "status": "Discharging"}, "ucsi": {"type": "USB", "online": "1"}}, false},
{"discharging usb-c offline", map[string]supply{"BAT0": {"type": "Battery", "status": "Discharging"}, "ucsi": {"type": "USB_C", "online": "0"}}, true},
{"adp supply online", map[string]supply{"BAT1": {"type": "Battery", "status": "Discharging"}, "ADP1": {"type": "ADP", "online": "1"}}, false},
{"peripheral battery ignored", map[string]supply{"hid": {"type": "Battery", "status": "Discharging", "scope": "Device"}, "AC": {"type": "Mains", "online": "1"}}, false},
{"peripheral only", map[string]supply{"hid": {"type": "Battery", "status": "Discharging", "scope": "Device"}}, false},
{"unknown status", map[string]supply{"BAT0": {"type": "Battery", "status": "Unknown"}}, false},
}
for _, item := range cases {
got, err := sysfsOnBattery(buildTree(t, item.supplies))
if err != nil || got != item.want {
t.Errorf("%s: got %v err %v", item.name, got, err)
}
}
}
func TestSysfsMissingRootIsAnError(t *testing.T) {
if _, err := sysfsOnBattery(filepath.Join(t.TempDir(), "nope")); err == nil {
t.Fatal("expected an error")
}
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !linux && !darwin && !windows
package main
import "errors"
func systemBattery() (bool, error) {
return false, errors.New("battery state is not available on this platform")
}
+28
View File
@@ -0,0 +1,28 @@
//go:build windows
package main
import (
"syscall"
"unsafe"
)
type systemPowerStatus struct {
ACLineStatus byte
BatteryFlag byte
BatteryLifePercent byte
SystemStatusFlag byte
BatteryLifeTime uint32
BatteryFullLifeTime uint32
}
var getSystemPowerStatus = syscall.NewLazyDLL("kernel32.dll").NewProc("GetSystemPowerStatus")
func systemBattery() (bool, error) {
var status systemPowerStatus
result, _, err := getSystemPowerStatus.Call(uintptr(unsafe.Pointer(&status)))
if result == 0 {
return false, err
}
return windowsOnBattery(status.ACLineStatus, status.BatteryFlag), nil
}
+12 -1
View File
@@ -27,6 +27,7 @@ type Limiter struct {
serverLimit ServerLimits
serverSet ServerSettings
hasServer bool
battery *batteryWatch
day string
used int64
active int
@@ -38,7 +39,7 @@ func NewLimiter(cfg Config, statePath string, now func() time.Time) (*Limiter, e
if err != nil {
return nil, err
}
limiter := &Limiter{cfg: cfg, loc: loc, now: now, statePath: statePath}
limiter := &Limiter{cfg: cfg, loc: loc, now: now, statePath: statePath, battery: newBatteryWatch(systemBattery, now)}
limiter.load()
return limiter, nil
}
@@ -165,6 +166,13 @@ func (l *Limiter) hoursCode() string {
return ""
}
func (l *Limiter) SetBatteryProbe(probe BatteryProbe, debugf func(format string, args ...any)) {
l.mu.Lock()
defer l.mu.Unlock()
l.battery = newBatteryWatch(probe, l.now)
l.battery.debugf = debugf
}
func (l *Limiter) Acquire() string {
l.mu.Lock()
defer l.mu.Unlock()
@@ -172,6 +180,9 @@ func (l *Limiter) Acquire() string {
if code := l.hoursCode(); code != "" {
return code
}
if l.serverSet.PauseOnBattery && l.battery.OnBattery() {
return RefuseBattery
}
if l.used >= l.capBytes() {
return RefuseBandwidth
}
+91
View File
@@ -1,6 +1,7 @@
package main
import (
"errors"
"path/filepath"
"testing"
"time"
@@ -160,3 +161,93 @@ func TestUsagePersistsAcrossRestart(t *testing.T) {
t.Fatalf("read usage %d", got)
}
}
func TestAcquireRefusesOnBatteryOnlyWhenAsked(t *testing.T) {
limiter, c := testLimiter(t, DefaultConfig(), "2026-01-15T12:00:00Z")
calls := 0
onBattery := true
limiter.SetBatteryProbe(func() (bool, error) { calls++; return onBattery, nil }, nil)
if got := limiter.Acquire(); got != "" {
t.Fatalf("setting off must ignore the probe: %q", got)
}
limiter.Release()
if calls != 0 {
t.Fatalf("probe ran %d times with the setting off", calls)
}
limiter.ApplyServer(nil, &ServerSettings{PauseOnBattery: true})
if got := limiter.Acquire(); got != RefuseBattery {
t.Fatalf("got %q", got)
}
onBattery = false
if got := limiter.Acquire(); got != RefuseBattery || calls != 1 {
t.Fatalf("cached result expected: %q calls %d", got, calls)
}
c.now = c.now.Add(16 * time.Second)
if got := limiter.Acquire(); got != "" || calls != 2 {
t.Fatalf("after cache expiry: %q calls %d", got, calls)
}
}
func TestBatteryProbeErrorMeansMainsAndLogsOnce(t *testing.T) {
limiter, c := testLimiter(t, DefaultConfig(), "2026-01-15T12:00:00Z")
logged := 0
limiter.SetBatteryProbe(func() (bool, error) { return true, errors.New("no sysfs") }, func(string, ...any) { logged++ })
limiter.ApplyServer(nil, &ServerSettings{PauseOnBattery: true})
for i := 0; i < 3; i++ {
if got := limiter.Acquire(); got != "" {
t.Fatalf("probe error must not refuse: %q", got)
}
limiter.Release()
c.now = c.now.Add(20 * time.Second)
}
if logged != 1 {
t.Fatalf("logged %d times", logged)
}
}
func TestBatteryHelpers(t *testing.T) {
if !parsePmset("Now drawing from 'Battery Power'\n -InternalBattery-0") || parsePmset("Now drawing from 'AC Power'") {
t.Fatal("pmset parse")
}
cases := []struct {
ac, flag byte
want bool
}{{0, 1, true}, {0, 8, true}, {1, 1, false}, {0, 128, false}, {255, 255, false}, {0, 255, false}}
for _, item := range cases {
if got := windowsOnBattery(item.ac, item.flag); got != item.want {
t.Errorf("ac %d flag %d: %v", item.ac, item.flag, got)
}
}
}
const (
parityBase = 1767225600
parityStep = 40009
paritySamples = 200
)
var parityCases = []struct{ from, to, zone, bits string }{
{"09:00", "17:00", "Europe/Amsterdam", "01010000001010101010000101010101000000101010100000010101010000001010101010000101010101000000101010100000010101010000001010101010000101010101000000101010100000010101010000001010101010000001010101010000"},
{"22:00", "06:00", "America/New_York", "00010101010000001010101000000101010101000010101010100000010101010000001010101000000101010101000010101010100000010101010000001010101000000101010101000000101010101000010101010100000010101010000001010101"},
{"08:30", "12:15", "Asia/Kolkata", "00000001010000000000101000000000010101000000000010100000000001010000000000101000000000000101000000000010100000000001010000000000101000000000000101000000000010100000000001010000000000101010000000000101"},
{"00:00", "12:00", "Pacific/Auckland", "01101010101010110101010101010010101010101001010101010101101010101010110101010101010010101010101001010101010101101010101010110101010101010010101010101011010101010101101010101010100101010101010010101010"},
{"10:00", "10:00", "UTC", "11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111"},
{"09:00", "17:00", "Not/AZone", "01010000101010101000000101010100000010101010000001010101010000001010101000000101010100000010101010100001010101010000001010101000000101010100000010101010100001010101010000001010101000000101010100000010"},
{"23:00", "01:00", "Australia/Lord_Howe", "00000000000010000000000001010000000000001000000000000100000000000010000000000001010000000000001000000000000100000000000010000000000000010000000000001000000000000100000000000010000000000000010000000000"},
{"", "", "Europe/Amsterdam", "11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111"},
}
func TestInWindowParityWithPythonScheduler(t *testing.T) {
for _, item := range parityCases {
zone, err := time.LoadLocation(item.zone)
if err != nil {
zone = time.UTC
}
for index := 0; index < paritySamples; index++ {
moment := time.Unix(int64(parityBase+index*parityStep), 0).In(zone)
if got := InWindow(item.from, item.to, moment); got != (item.bits[index] == '1') {
t.Fatalf("%s-%s %s #%d: got %v", item.from, item.to, item.zone, index, got)
}
}
}
}
+1
View File
@@ -189,6 +189,7 @@ func runRun(args []string, stderr io.Writer) int {
fmt.Fprintln(stderr, err)
return exitError
}
limiter.SetBatteryProbe(systemBattery, logger.Debugf)
policy := NewPolicy()
stats := NewStats(time.Now())
session := NewSession(config, policy, limiter, NewFetcher(policy), stats, logger)
+10 -1
View File
@@ -37,6 +37,12 @@ const (
MaxDonorMBPerDay = 20 * 1024
StableSessionSeconds = 30
FastRetryMillis = 1500
SuspendedReason = "suspended"
SuspendedMinSeconds = 60
SuspendedMaxSeconds = 24 * 60 * 60
SuspendedDefaultSeconds = 300
SuspendedJitterSeconds = 30
)
const (
@@ -48,6 +54,7 @@ const (
RefuseBandwidth = "bandwidth_exhausted"
RefuseConcurrency = "concurrency_exceeded"
RefuseHours = "outside_hours"
RefuseBattery = "on_battery"
RefuseTimeout = "timeout"
RefuseBadURL = "bad_url"
FailFetch = "fetch_failed"
@@ -71,7 +78,6 @@ const (
var FatalReasons = map[string]bool{
"revoked": true,
"suspended": true,
"kill_switch": true,
"no_consent": true,
"no_account": true,
@@ -117,6 +123,7 @@ type ServerSettings struct {
ActiveFrom string `json:"active_from"`
ActiveTo string `json:"active_to"`
Timezone string `json:"timezone"`
PauseOnBattery bool `json:"pause_on_battery"`
}
type Frame struct {
@@ -131,6 +138,8 @@ type Frame struct {
Port int `json:"port"`
B64 string `json:"b64"`
Reason string `json:"reason"`
Until string `json:"until"`
DeviceLabel string `json:"device_label"`
DeviceUID string `json:"device_uid"`
Limits *ServerLimits `json:"limits"`
Destinations []Destination `json:"destinations"`
+43 -9
View File
@@ -25,10 +25,11 @@ func (e *FatalError) Error() string {
}
type outcome struct {
fatal string
fast bool
stable bool
reason string
fatal string
fast bool
stable bool
reason string
holdUntil time.Time
}
type Session struct {
@@ -40,6 +41,8 @@ type Session struct {
log *Logger
httpc *http.Client
jitter func() float64
now func() time.Time
sleep func(ctx context.Context, d time.Duration)
dialWS func(ctx context.Context, rawURL string) (*Conn, error)
limits ServerLimits
maxBody int
@@ -55,6 +58,8 @@ func NewSession(cfg Config, policy *Policy, limiter *Limiter, fetcher *Fetcher,
log: logger,
httpc: &http.Client{Timeout: time.Duration(TicketTimeoutSeconds) * time.Second, Transport: &http.Transport{Proxy: http.ProxyFromEnvironment}},
jitter: rand.Float64,
now: time.Now,
sleep: sleepContext,
dialWS: Dial,
limits: defaultLimits(),
maxBody: DefaultMaxBodyBytes,
@@ -101,6 +106,21 @@ func sleepContext(ctx context.Context, d time.Duration) {
}
}
func (s *Session) holdDelay(until time.Time) time.Duration {
wait := until.Sub(s.now())
wait = max(wait, time.Duration(SuspendedMinSeconds)*time.Second)
wait = min(wait, time.Duration(SuspendedMaxSeconds)*time.Second)
return wait + time.Duration(s.jitter()*float64(SuspendedJitterSeconds)*float64(time.Second))
}
func (s *Session) suspension(until string) outcome {
end := s.now().Add(time.Duration(SuspendedDefaultSeconds) * time.Second)
if moment, err := time.Parse(time.RFC3339, until); err == nil {
end = moment
}
return outcome{reason: "device is suspended", holdUntil: end}
}
func (s *Session) Run(ctx context.Context) error {
attempt := 0
for ctx.Err() == nil {
@@ -115,6 +135,13 @@ func (s *Session) Run(ctx context.Context) error {
attempt = 0
}
var delay time.Duration
if !result.holdUntil.IsZero() {
delay = s.holdDelay(result.holdUntil)
attempt = 0
s.log.Printf("device is suspended until %s, waiting %s", result.holdUntil.UTC().Format(time.RFC3339), delay.Round(time.Second))
s.sleep(ctx, delay)
continue
}
if result.fast {
delay = time.Duration((0.5 + s.jitter()) * float64(FastRetryMillis) * float64(time.Millisecond))
} else {
@@ -122,7 +149,7 @@ func (s *Session) Run(ctx context.Context) error {
attempt++
}
s.log.Printf("disconnected (%s), reconnecting in %s", result.reason, delay.Round(100*time.Millisecond))
sleepContext(ctx, delay)
s.sleep(ctx, delay)
}
return nil
}
@@ -136,6 +163,7 @@ type errorAnswer struct {
Error struct {
Message string `json:"message"`
Reason string `json:"reason"`
Until string `json:"until"`
} `json:"error"`
}
@@ -166,6 +194,9 @@ func (s *Session) fetchTicket(ctx context.Context) (ticketAnswer, outcome) {
var failure errorAnswer
_ = json.Unmarshal(body, &failure)
reason := failure.Error.Reason
if reason == SuspendedReason {
return answer, s.suspension(failure.Error.Until)
}
if FatalReasons[reason] {
return answer, outcome{fatal: reason}
}
@@ -292,13 +323,13 @@ func (s *Session) converse(ctx context.Context, conn *Conn) outcome {
return s.protocolFailure(conn, "welcome is not valid json")
}
if welcome.Type == "kick" {
return s.kickOutcome(welcome.Reason)
return s.kickOutcome(welcome.Reason, welcome.Until)
}
if welcome.Type != "welcome" {
return s.protocolFailure(conn, "expected welcome, got "+welcome.Type)
}
s.applyWelcome(welcome)
s.log.Debugf("connected as device %s with %d allowed destinations", welcome.DeviceUID, len(s.policy.Destinations()))
s.log.Debugf("connected as device %s (%s) with %d allowed destinations", welcome.DeviceUID, welcome.DeviceLabel, len(s.policy.Destinations()))
var jobs sync.WaitGroup
tunnels := NewTunnels(s.policy, s.limiter, s.stats, send, s.cfg.MaxTunnels)
@@ -342,7 +373,7 @@ func (s *Session) converse(ctx context.Context, conn *Conn) outcome {
case "settings_update":
s.limiter.ApplyServer(nil, frame.Settings)
case "kick":
return s.kickOutcome(frame.Reason)
return s.kickOutcome(frame.Reason, frame.Until)
default:
return s.protocolFailure(conn, "unexpected frame "+frame.Type)
}
@@ -355,7 +386,10 @@ func (s *Session) protocolFailure(conn *Conn, reason string) outcome {
return outcome{reason: "protocol error: " + reason}
}
func (s *Session) kickOutcome(reason string) outcome {
func (s *Session) kickOutcome(reason, until string) outcome {
if reason == SuspendedReason {
return s.suspension(until)
}
if FatalReasons[reason] {
return outcome{fatal: reason}
}
+112 -5
View File
@@ -268,12 +268,12 @@ func TestSessionLimitsAnswerWithDonorCodes(t *testing.T) {
_ = c.WriteClose(CloseGoingAway, "bye")
return
}
writeFrame(t, c, map[string]any{"type": "kick", "reason": "suspended"})
writeFrame(t, c, map[string]any{"type": "kick", "reason": "revoked"})
})
h.session.limiter.AddBytes(1 << 40)
err := h.run(t)
var fatal *FatalError
if !errors.As(err, &fatal) || fatal.Reason != "suspended" {
if !errors.As(err, &fatal) || fatal.Reason != "revoked" {
t.Fatalf("run returned %v", err)
}
}
@@ -286,7 +286,8 @@ func TestFatalTicketStatuses(t *testing.T) {
}{
{401, "", "unauthorized"},
{403, `{"error":{"status":403,"message":"x","reason":"no_consent"}}`, "no_consent"},
{403, `{"error":{"status":403,"message":"x","reason":"suspended"}}`, "suspended"},
{403, `{"error":{"status":403,"message":"x","reason":"revoked"}}`, "revoked"},
{403, `{"error":{"status":403,"message":"x","reason":"kill_switch"}}`, "kill_switch"},
}
for _, item := range cases {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -349,14 +350,120 @@ func TestBackoffStaysInsideBounds(t *testing.T) {
}
func TestFatalReasonsMatchPlan(t *testing.T) {
for _, reason := range []string{"revoked", "suspended", "kill_switch", "no_consent"} {
for _, reason := range []string{"revoked", "kill_switch", "no_consent"} {
if !FatalReasons[reason] {
t.Errorf("%s must stop reconnecting", reason)
}
}
for _, reason := range []string{"heartbeat", "ticket", "capacity"} {
for _, reason := range []string{"heartbeat", "ticket", "capacity", "suspended"} {
if FatalReasons[reason] {
t.Errorf("%s must reconnect", reason)
}
}
}
type sleepLog struct{ waits []time.Duration }
func (l *sleepLog) sleep(_ context.Context, d time.Duration) { l.waits = append(l.waits, d) }
func fixedNow(text string) func() time.Time {
moment, _ := time.Parse(time.RFC3339, text)
return func() time.Time { return moment }
}
func TestSuspendedTicketWaitsUntilTheEndThenRetries(t *testing.T) {
cases := []struct {
name string
until string
want time.Duration
}{
{"future", "2026-01-01T01:00:00Z", time.Hour},
{"empty", "", 300 * time.Second},
{"garbage", "tomorrow", 300 * time.Second},
{"expired", "2025-12-31T00:00:00Z", 60 * time.Second},
{"far", "2030-01-01T00:00:00Z", 24 * time.Hour},
}
for _, item := range cases {
t.Run(item.name, func(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if calls.Add(1) == 1 {
w.WriteHeader(http.StatusForbidden)
body, _ := json.Marshal(map[string]any{"error": map[string]any{"status": 403, "message": "x", "reason": "suspended", "until": item.until}})
_, _ = w.Write(body)
return
}
w.WriteHeader(http.StatusUnauthorized)
}))
defer server.Close()
cfg := DefaultConfig()
cfg.Server, cfg.Secret = server.URL, testSecret
limiter, _ := NewLimiter(cfg, "", time.Now)
policy := testPolicy(publicAnswer)
session := NewSession(cfg, policy, limiter, NewFetcher(policy), NewStats(time.Now()), NewLogger(testWriter{t}, true))
session.jitter = func() float64 { return 0 }
session.now = fixedNow("2026-01-01T00:00:00Z")
slept := &sleepLog{}
session.sleep = slept.sleep
var fatal *FatalError
if err := session.Run(context.Background()); !errors.As(err, &fatal) || calls.Load() != 2 {
t.Fatalf("err %v calls %d", err, calls.Load())
}
if len(slept.waits) != 1 || slept.waits[0] != item.want {
t.Fatalf("waits %v want %v", slept.waits, item.want)
}
})
}
}
func TestSuspendedKickHoldsThenReconnects(t *testing.T) {
h := newHarness(t, func(n int, c *Conn) {
readFrame(t, c)
writeFrame(t, c, welcome())
if n == 1 {
writeFrame(t, c, map[string]any{"type": "kick", "reason": "suspended", "until": "2026-01-01T00:10:00Z"})
return
}
writeFrame(t, c, map[string]any{"type": "kick", "reason": "revoked"})
})
h.session.now = fixedNow("2026-01-01T00:00:00Z")
slept := &sleepLog{}
h.session.sleep = slept.sleep
var fatal *FatalError
if err := h.run(t); !errors.As(err, &fatal) || fatal.Reason != "revoked" {
t.Fatalf("run returned %v", err)
}
if h.conns.Load() != 2 || len(slept.waits) != 1 || slept.waits[0] != 10*time.Minute {
t.Fatalf("conns %d waits %v", h.conns.Load(), slept.waits)
}
}
func TestSuspendedWaitHonoursCancel(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"error":{"status":403,"message":"x","reason":"suspended","until":""}}`))
}))
defer server.Close()
cfg := DefaultConfig()
cfg.Server, cfg.Secret = server.URL, testSecret
limiter, _ := NewLimiter(cfg, "", time.Now)
policy := testPolicy(publicAnswer)
session := NewSession(cfg, policy, limiter, NewFetcher(policy), NewStats(time.Now()), NewLogger(testWriter{t}, true))
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
started := time.Now()
if err := session.Run(ctx); err != nil || time.Since(started) > 5*time.Second {
t.Fatalf("err %v after %v", err, time.Since(started))
}
}
func TestWelcomeDecodesDeviceLabel(t *testing.T) {
frame, err := decodeFrame([]byte(`{"type":"welcome","device_uid":"d","device_label":"Laptop","settings":{"pause_on_battery":true}}`))
if err != nil || frame.DeviceLabel != "Laptop" || !frame.Settings.PauseOnBattery {
t.Fatalf("%+v %v", frame, err)
}
kick, _ := decodeFrame([]byte(`{"type":"kick","reason":"suspended","until":"2026-01-01T00:00:00Z"}`))
if kick.Until == "" {
t.Fatal("until not decoded")
}
}