Files
claude d7a43afd90 A hung workstation no longer costs the resident model its budget (V-581)
Pair.Complete handed the caller's context to the workstation unchanged, so a
remote that accepted the connection and then hung spent the whole turn budget.
The fallback then ran on an expired context and the floor returned the deadline
error instead of an answer, which broke the turn on the workstation being slow.
docs/offload.md rules that out. The remote now gets at most half of a deadline
that exists, and a context without a deadline is left to the configured
workstation timeout.

Pair.Stop and worker.Server.Close both closed their channel after a
check-then-close, so two concurrent callers could race and the second close
panics. Both are sync.Once now, which is what the doc comments already claimed.

config.Load names the environment variables it could not resolve. An unset
variable still expands to the empty string, because every block reads that as
not configured and CI parses deploy/mavend.json with no secrets present. What
was missing is the line telling the operator which capability a forgotten env
file just turned off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:31:19 +04:00

221 lines
6.4 KiB
Go

// worker/server.go — the worker-side listener + accept loop.
//
// Each call is dispatched to a single handler (Transcriber OR Synthesizer,
// depending on what the module process);
// the other verb returns ErrUnknownMethod — a stt process won't serve
// synthesize. robust to a misconfigured client (the daemon wiring chooses
// which module to dial; mixing the two is a config error caught cleanly by
// the wire, not a runtime goroutine panic). One Server per module process.
//
// The seam address decides the transport. On the default unix socket the
// perms mirror ipc.Server — dir 0700, socket 0600 ⇒ same unix user — and that
// is the whole auth floor, because the seam never leaves the box. A tcp
// address moves the module to another host and takes that floor away, so
// netaddr checks a shared token before the first frame. See internal/netaddr.
package worker
import (
"context"
"encoding/json"
"fmt"
"net"
"sync"
"sync/atomic"
"github.com/kami/maven/internal/netaddr"
)
// Server — a worker module process's listener. Wires either a Transcriber,
// a Synthesizer, or both (the both case is unusual; the daemon typically
// runs two separate module processes). The unset verb returns
// ErrUnknownMethod.
type Server struct {
t Transcriber
s Synthesizer
path string
addr netaddr.Addr
ln net.Listener
wg sync.WaitGroup
done chan struct{}
closeOnce sync.Once
// connCount — assigned per accepted conn, used in logs to distinguish
// concurrent connections. Monotonic; not load-bearing for correctness.
connCount atomic.Uint64
}
// NewServer builds a Server with a Transcriber. The caller wires a
// Synthesizer via SetSynthesizer if this process serves tts. Use
// NewSynthesizerServer for the tts-only case (mirrors this constructor).
func NewServer(path string, t Transcriber) *Server {
return &Server{t: t, path: path, done: make(chan struct{})}
}
// NewSynthesizerServer builds a Server with a Synthesizer (the tts module).
func NewSynthesizerServer(path string, s Synthesizer) *Server {
return &Server{s: s, path: path, done: make(chan struct{})}
}
// SetSynthesizer wires the synthesize verb on a Transcriber-built Server.
// Used only when one process serves both (non-default; the daemon prefers
// two separate processes per the restart-free / fail-independent invariant).
func (srv *Server) SetSynthesizer(s Synthesizer) { srv.s = s }
// Listen binds the seam address the Server was built with.
//
// A bare path is a unix socket with 0700 dir + 0600 socket perms, the same
// floor as internal/ipc, and a stale socket is removed first so the worker
// process restarts cleanly after a crash. A "tcp://host:port?token=..."
// address binds a network listener instead, so this module can run on the
// workstation while core stays on homesrv; the token is mandatory there,
// because there is no filesystem to be the auth floor. See internal/netaddr.
func (srv *Server) Listen() error {
addr, err := netaddr.Parse(srv.path)
if err != nil {
return err
}
ln, err := netaddr.Listen(addr)
if err != nil {
return err
}
srv.addr = addr
srv.ln = ln
return nil
}
// Path returns the bound socket path (after Listen; "" before).
func (srv *Server) Path() string { return srv.path }
// Serve accepts connections until the listener closes. Each connection is
// served in its own goroutine; a panicking handler tears down only that conn
// (the rest of the module keeps serving, restart-free per spec).
func (srv *Server) Serve() error {
if srv.ln == nil {
return fmt.Errorf("worker: serve before listen")
}
for {
c, err := srv.ln.Accept()
if err != nil {
select {
case <-srv.done:
return nil
default:
return fmt.Errorf("worker: accept: %w", err)
}
}
srv.wg.Add(1)
go func(c net.Conn) {
defer srv.wg.Done()
defer c.Close()
srv.serveConn(c)
}(c)
}
}
func (srv *Server) serveConn(c net.Conn) {
id := srv.connCount.Add(1)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
for {
var req Request
if err := readFrame(c, &req); err != nil {
return // EOF / malformed ⇒ end this conn
}
result, err := srv.safeDispatch(ctx, c.RemoteAddr(), id, req)
resp := Response{}
if err != nil {
resp.Error = rpcErr(err)
} else {
resp.Result = result
}
if err := writeFrame(c, resp); err != nil {
return
}
}
}
func (srv *Server) safeDispatch(ctx context.Context, addr net.Addr, id uint64, req Request) (result json.RawMessage, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("worker: panic dispatching %s (conn %d): %v", req.Method, id, r)
}
}()
return srv.dispatch(ctx, req)
}
func (srv *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, error) {
switch req.Method {
case MethodTranscribe:
if srv.t == nil {
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
}
var p TranscribeReq
if err := unmarshalParams(req.Params, &p); err != nil {
return nil, err
}
resp, err := srv.t.Transcribe(ctx, p)
if err != nil {
return nil, err
}
return marshalResult(resp), nil
case MethodSynthesize:
if srv.s == nil {
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
}
var p SynthesizeReq
if err := unmarshalParams(req.Params, &p); err != nil {
return nil, err
}
resp, err := srv.s.Synthesize(ctx, p)
if err != nil {
return nil, err
}
return marshalResult(resp), nil
default:
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
}
}
// Close stops accepting and waits for in-flight connections to drain. The
// socket file is removed so a restart can rebind cleanly.
//
// Idempotent, and safe from two goroutines at once. The check-then-close it
// replaced let both callers see an open channel and the second close panicked,
// so a shutdown racing a signal handler took the process down the one way a
// clean shutdown is supposed to prevent.
func (srv *Server) Close() error {
var err error
srv.closeOnce.Do(func() {
close(srv.done)
if srv.ln == nil {
return
}
err = srv.ln.Close()
srv.wg.Wait()
netaddr.Cleanup(srv.addr)
})
return err
}
func unmarshalParams(raw json.RawMessage, v any) error {
if len(raw) == 0 {
raw = []byte("null")
}
if err := json.Unmarshal(raw, v); err != nil {
return fmt.Errorf("%w: %v", ErrBadParams, err)
}
return nil
}
func marshalResult(v any) json.RawMessage {
if v == nil {
return json.RawMessage("null")
}
b, _ := json.Marshal(v)
return b
}