Files
devplacepy/nodes/client/session.go
T
retoorandClaude Sonnet 5.5 02ab747cd0
DevPlace CI / test (push) Failing after 44m45s
Code Farm: per-plot auto-replant pause and crop-lock check; community sync and node work
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>
2026-10-17 02:23:43 +02:00

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)
}
}