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>
480 lines
13 KiB
Go
480 lines
13 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math/rand"
|
|
"net/http"
|
|
"net/url"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
type FatalError struct {
|
|
Reason string
|
|
}
|
|
|
|
func (e *FatalError) Error() string {
|
|
return "the server ended this device for good: " + e.Reason
|
|
}
|
|
|
|
type outcome struct {
|
|
fatal string
|
|
fast bool
|
|
stable bool
|
|
reason string
|
|
holdUntil time.Time
|
|
}
|
|
|
|
type Session struct {
|
|
cfg Config
|
|
policy *Policy
|
|
limiter *Limiter
|
|
fetcher *Fetcher
|
|
stats *Stats
|
|
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
|
|
}
|
|
|
|
func NewSession(cfg Config, policy *Policy, limiter *Limiter, fetcher *Fetcher, stats *Stats, logger *Logger) *Session {
|
|
return &Session{
|
|
cfg: cfg,
|
|
policy: policy,
|
|
limiter: limiter,
|
|
fetcher: fetcher,
|
|
stats: stats,
|
|
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,
|
|
}
|
|
}
|
|
|
|
func defaultLimits() ServerLimits {
|
|
return ServerLimits{
|
|
MaxBodyBytes: DefaultMaxBodyBytes,
|
|
PingSeconds: DefaultPingSeconds,
|
|
ReconnectMinSeconds: DefaultReconnectMin,
|
|
ReconnectMaxSeconds: DefaultReconnectMax,
|
|
JobTimeoutSeconds: DefaultJobTimeout,
|
|
MaxFrameBytes: MaxInboundMessage,
|
|
}
|
|
}
|
|
|
|
func (s *Session) Backoff(attempt int) time.Duration {
|
|
low := s.limits.ReconnectMinSeconds
|
|
high := s.limits.ReconnectMaxSeconds
|
|
if low < 1 {
|
|
low = DefaultReconnectMin
|
|
}
|
|
if high < low {
|
|
high = low
|
|
}
|
|
if attempt > 16 {
|
|
attempt = 16
|
|
}
|
|
ceiling := low << attempt
|
|
if ceiling > high || ceiling <= 0 {
|
|
ceiling = high
|
|
}
|
|
seconds := float64(low) + s.jitter()*float64(ceiling-low)
|
|
return time.Duration(seconds * float64(time.Second))
|
|
}
|
|
|
|
func sleepContext(ctx context.Context, d time.Duration) {
|
|
timer := time.NewTimer(d)
|
|
defer timer.Stop()
|
|
select {
|
|
case <-ctx.Done():
|
|
case <-timer.C:
|
|
}
|
|
}
|
|
|
|
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 {
|
|
result := s.connectOnce(ctx)
|
|
if result.fatal != "" {
|
|
return &FatalError{Reason: result.fatal}
|
|
}
|
|
if ctx.Err() != nil {
|
|
break
|
|
}
|
|
if result.stable {
|
|
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 {
|
|
delay = s.Backoff(attempt)
|
|
attempt++
|
|
}
|
|
s.log.Printf("disconnected (%s), reconnecting in %s", result.reason, delay.Round(100*time.Millisecond))
|
|
s.sleep(ctx, delay)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type ticketAnswer struct {
|
|
Ticket string `json:"ticket"`
|
|
WSPath string `json:"ws_path"`
|
|
}
|
|
|
|
type errorAnswer struct {
|
|
Error struct {
|
|
Message string `json:"message"`
|
|
Reason string `json:"reason"`
|
|
Until string `json:"until"`
|
|
} `json:"error"`
|
|
}
|
|
|
|
func (s *Session) fetchTicket(ctx context.Context) (ticketAnswer, outcome) {
|
|
var answer ticketAnswer
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodPost, s.cfg.BaseURL()+"/nodes/ticket", nil)
|
|
if err != nil {
|
|
return answer, outcome{fatal: "bad server url"}
|
|
}
|
|
request.Header.Set("Authorization", "Bearer "+s.cfg.Secret)
|
|
request.Header.Set("Accept", "application/json")
|
|
request.Header.Set("User-Agent", "devplace-node/"+Version)
|
|
response, err := s.httpc.Do(request)
|
|
if err != nil {
|
|
return answer, outcome{reason: "ticket request failed: " + stripURL(err).Error()}
|
|
}
|
|
defer response.Body.Close()
|
|
body, _ := io.ReadAll(io.LimitReader(response.Body, 64*1024))
|
|
switch {
|
|
case response.StatusCode == http.StatusOK:
|
|
if json.Unmarshal(body, &answer) != nil || answer.Ticket == "" || !strings.HasPrefix(answer.WSPath, "/") {
|
|
return answer, outcome{reason: "ticket answer is malformed"}
|
|
}
|
|
return answer, outcome{}
|
|
case response.StatusCode == http.StatusUnauthorized:
|
|
return answer, outcome{fatal: "unauthorized, the device secret is not valid (revoked or rotated)"}
|
|
case response.StatusCode == http.StatusForbidden:
|
|
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}
|
|
}
|
|
return answer, outcome{reason: "ticket refused: " + reason}
|
|
default:
|
|
return answer, outcome{reason: fmt.Sprintf("ticket request answered http %d", response.StatusCode)}
|
|
}
|
|
}
|
|
|
|
func (s *Session) socketURL(answer ticketAnswer) (string, error) {
|
|
base, err := url.Parse(s.cfg.BaseURL())
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
scheme := "ws"
|
|
if base.Scheme == "https" {
|
|
scheme = "wss"
|
|
}
|
|
return fmt.Sprintf("%s://%s%s?ticket=%s", scheme, base.Host, answer.WSPath, url.QueryEscape(answer.Ticket)), nil
|
|
}
|
|
|
|
func (s *Session) connectOnce(ctx context.Context) outcome {
|
|
answer, failed := s.fetchTicket(ctx)
|
|
if failed.fatal != "" || failed.reason != "" {
|
|
return failed
|
|
}
|
|
target, err := s.socketURL(answer)
|
|
if err != nil {
|
|
return outcome{fatal: "bad server url"}
|
|
}
|
|
conn, err := s.dialWS(ctx, target)
|
|
if err != nil {
|
|
return outcome{reason: err.Error()}
|
|
}
|
|
defer conn.Close()
|
|
sessionCtx, cancel := context.WithCancel(ctx)
|
|
defer cancel()
|
|
go func() {
|
|
<-sessionCtx.Done()
|
|
conn.Close()
|
|
}()
|
|
started := time.Now()
|
|
result := s.converse(sessionCtx, conn)
|
|
if time.Since(started) >= time.Duration(StableSessionSeconds)*time.Second {
|
|
result.stable = true
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (s *Session) sender(conn *Conn) Sender {
|
|
return func(frameType string, fields map[string]any) error {
|
|
payload := map[string]any{"type": frameType}
|
|
for key, value := range fields {
|
|
payload[key] = value
|
|
}
|
|
data, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
limit := MaxInboundMessage
|
|
if frameType == "job_result" {
|
|
limit = MaxResultMessage
|
|
}
|
|
if len(data) > limit {
|
|
return fmt.Errorf("%s frame of %d bytes exceeds the %d byte cap", frameType, len(data), limit)
|
|
}
|
|
return conn.WriteText(string(data))
|
|
}
|
|
}
|
|
|
|
func (s *Session) helloFields() map[string]any {
|
|
return map[string]any{
|
|
"protocol": ProtocolVersion,
|
|
"client_version": Version,
|
|
"kind": DeviceKind,
|
|
"platform": runtime.GOOS + "/" + runtime.GOARCH,
|
|
"modes": []string{"fetch", "tunnel"},
|
|
"limits": map[string]any{"max_concurrent": s.limiter.ConcurrencyCap()},
|
|
}
|
|
}
|
|
|
|
func (s *Session) applyWelcome(frame Frame) {
|
|
limits := defaultLimits()
|
|
if frame.Limits != nil {
|
|
given := *frame.Limits
|
|
if given.MaxBodyBytes > 0 {
|
|
limits.MaxBodyBytes = given.MaxBodyBytes
|
|
}
|
|
if given.PingSeconds > 0 {
|
|
limits.PingSeconds = given.PingSeconds
|
|
}
|
|
if given.ReconnectMinSeconds > 0 {
|
|
limits.ReconnectMinSeconds = given.ReconnectMinSeconds
|
|
}
|
|
if given.ReconnectMaxSeconds > 0 {
|
|
limits.ReconnectMaxSeconds = given.ReconnectMaxSeconds
|
|
}
|
|
limits.MaxConcurrentPerNode = given.MaxConcurrentPerNode
|
|
limits.MaxDonorMBPerDay = given.MaxDonorMBPerDay
|
|
if given.JobTimeoutSeconds > 0 {
|
|
limits.JobTimeoutSeconds = given.JobTimeoutSeconds
|
|
}
|
|
}
|
|
s.limits = limits
|
|
s.maxBody = min(limits.MaxBodyBytes, DefaultMaxBodyBytes)
|
|
if rejected := s.policy.Refine(frame.Destinations); len(rejected) > 0 {
|
|
s.log.Printf("server offered destinations outside the baked-in list, ignored: %s", strings.Join(rejected, ", "))
|
|
}
|
|
s.limiter.ApplyServer(&limits, frame.Settings)
|
|
}
|
|
|
|
func (s *Session) converse(ctx context.Context, conn *Conn) outcome {
|
|
send := s.sender(conn)
|
|
if err := send("hello", s.helloFields()); err != nil {
|
|
return outcome{reason: "hello failed: " + err.Error()}
|
|
}
|
|
_ = conn.SetReadDeadline(time.Now().Add(time.Duration(HelloTimeoutSeconds) * time.Second))
|
|
raw, err := conn.ReadMessage()
|
|
if err != nil {
|
|
return s.closeOutcome(err)
|
|
}
|
|
welcome, err := decodeFrame(raw)
|
|
if err != nil {
|
|
return s.protocolFailure(conn, "welcome is not valid json")
|
|
}
|
|
if welcome.Type == "kick" {
|
|
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 (%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)
|
|
background, stop := context.WithCancel(ctx)
|
|
jobs.Add(1)
|
|
go func() {
|
|
defer jobs.Done()
|
|
s.housekeeping(background, send)
|
|
}()
|
|
defer func() {
|
|
stop()
|
|
tunnels.CloseAll()
|
|
jobs.Wait()
|
|
s.limiter.Flush()
|
|
}()
|
|
|
|
patience := time.Duration(s.limits.PingSeconds*MissedPingsBeforeDead) * time.Second
|
|
for ctx.Err() == nil {
|
|
_ = conn.SetReadDeadline(time.Now().Add(patience))
|
|
raw, err := conn.ReadMessage()
|
|
if err != nil {
|
|
return s.closeOutcome(err)
|
|
}
|
|
frame, err := decodeFrame(raw)
|
|
if err != nil {
|
|
return s.protocolFailure(conn, "frame is not valid json")
|
|
}
|
|
switch frame.Type {
|
|
case "ping":
|
|
if err := send("pong", nil); err != nil {
|
|
return outcome{reason: "pong failed: " + err.Error()}
|
|
}
|
|
case "job":
|
|
s.startJob(background, &jobs, send, frame)
|
|
case "tunnel_open":
|
|
tunnels.Open(background, frame)
|
|
case "tunnel_data":
|
|
tunnels.Data(frame)
|
|
case "tunnel_close":
|
|
tunnels.Close(frame.ConnID)
|
|
case "settings_update":
|
|
s.limiter.ApplyServer(nil, frame.Settings)
|
|
case "kick":
|
|
return s.kickOutcome(frame.Reason, frame.Until)
|
|
default:
|
|
return s.protocolFailure(conn, "unexpected frame "+frame.Type)
|
|
}
|
|
}
|
|
return outcome{reason: "stopped"}
|
|
}
|
|
|
|
func (s *Session) protocolFailure(conn *Conn, reason string) outcome {
|
|
_ = conn.WriteClose(CloseProtocol, "protocol")
|
|
return outcome{reason: "protocol error: " + reason}
|
|
}
|
|
|
|
func (s *Session) kickOutcome(reason, until string) outcome {
|
|
if reason == SuspendedReason {
|
|
return s.suspension(until)
|
|
}
|
|
if FatalReasons[reason] {
|
|
return outcome{fatal: reason}
|
|
}
|
|
return outcome{reason: "kicked: " + reason}
|
|
}
|
|
|
|
func (s *Session) closeOutcome(err error) outcome {
|
|
var closeErr *CloseError
|
|
if !errors.As(err, &closeErr) {
|
|
return outcome{reason: "connection lost: " + err.Error()}
|
|
}
|
|
if FatalReasons[closeErr.Reason] {
|
|
return outcome{fatal: closeErr.Reason}
|
|
}
|
|
if closeErr.Code == CloseRetryOwner {
|
|
return outcome{fast: true, reason: "server asked for a retry on the owner worker"}
|
|
}
|
|
return outcome{reason: closeErr.Error()}
|
|
}
|
|
|
|
func (s *Session) housekeeping(ctx context.Context, send Sender) {
|
|
ticker := time.NewTicker(time.Duration(UsageIntervalSeconds) * time.Second)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
if line := s.stats.Roll(time.Now()); line != "" {
|
|
s.log.Printf("%s", line)
|
|
}
|
|
up, down := s.stats.Take()
|
|
if up+down > 0 {
|
|
_ = send("usage", map[string]any{"bytes_up": up, "bytes_down": down})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Session) startJob(ctx context.Context, jobs *sync.WaitGroup, send Sender, frame Frame) {
|
|
if frame.JobID == "" || len(frame.JobID) > MaxIDBytes {
|
|
s.log.Printf("job without a usable id ignored")
|
|
return
|
|
}
|
|
if code := s.limiter.Acquire(); code != "" {
|
|
s.stats.Refused()
|
|
_ = send("job_error", map[string]any{"job_id": frame.JobID, "code": code})
|
|
return
|
|
}
|
|
jobs.Add(1)
|
|
go func() {
|
|
defer jobs.Done()
|
|
defer s.limiter.Release()
|
|
s.runJob(ctx, send, frame)
|
|
}()
|
|
}
|
|
|
|
func (s *Session) runJob(ctx context.Context, send Sender, frame Frame) {
|
|
result, err := s.fetcher.Do(ctx, frame, s.maxBody, s.limiter.Remaining())
|
|
if err != nil {
|
|
code := ErrorCode(err)
|
|
var policyErr *PolicyError
|
|
if errors.As(err, &policyErr) {
|
|
s.stats.Refused()
|
|
} else {
|
|
s.stats.Failed()
|
|
}
|
|
s.log.Debugf("job %s failed: %s", frame.JobID, code)
|
|
_ = send("job_error", map[string]any{"job_id": frame.JobID, "code": code})
|
|
return
|
|
}
|
|
up := int64(len(frame.URL) + 256)
|
|
down := int64(len(result.Body))
|
|
s.limiter.AddBytes(up + down)
|
|
s.stats.Job(up, down)
|
|
fields := map[string]any{
|
|
"job_id": frame.JobID,
|
|
"status": result.Status,
|
|
"headers": result.Headers,
|
|
"body_b64": base64.StdEncoding.EncodeToString(result.Body),
|
|
"truncated": result.Truncated,
|
|
"elapsed_ms": int(result.Elapsed.Milliseconds()),
|
|
}
|
|
if err := send("job_result", fields); err != nil {
|
|
s.log.Printf("job %s result could not be sent: %v", frame.JobID, err)
|
|
}
|
|
}
|