cbd8077d2c
serveConn dispatched under context.Background(), so a dispatch in flight during shutdown could not be told to stop and closeGrace could only abandon it. The server now carries a context, Close cancels it, and each conn derives its own so nothing outlives the connection. A zero-value Server built outside Listen falls back to Background; two wiring tests do that. Client.Close read c.conn with no lock while roundtrip re-dialed and dropped it, which -race caught on the new test. The conn field now has its own mutex, held only across a read or an assignment, so Close and the watchdog reach the connection without queueing behind the call they are interrupting.
864 lines
34 KiB
Go
864 lines
34 KiB
Go
package ipc
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/netaddr"
|
|
"golang.org/x/sys/unix"
|
|
)
|
|
|
|
// Server — the core side of the boundary. Listens on a unix domain socket,
|
|
// accepts module connections, frames requests to a CoreAPI and responses back.
|
|
// One Server per daemon process; concurrent connections are handled in their
|
|
// own goroutine but share the single CoreAPI (and therefore the single store
|
|
// writer — store is single-connection, SetMaxOpenConns(1), so serialization is
|
|
// already guaranteed at the db; the Server adds no locking of its own).
|
|
type Server struct {
|
|
api atomic.Value // stores CoreAPI
|
|
path string
|
|
addr netaddr.Addr
|
|
|
|
ln net.Listener
|
|
wg sync.WaitGroup
|
|
done chan struct{}
|
|
accept sync.Mutex // guards wg.Add vs Close's wg.Wait sequence
|
|
|
|
// ctx — server-scoped, cancelled by Close, and the parent of every request
|
|
// context. serveConn dispatched under context.Background() until V-638, so
|
|
// a dispatch in flight during shutdown could not be told to stop and the
|
|
// closeGrace below could only abandon it. Cancelling gives a handler that
|
|
// respects its context the chance to return instead.
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
|
|
// conns — every accepted connection still being served. Close needs these
|
|
// because closing the listener does nothing to a connection already
|
|
// accepted: serveConn is parked in readFrame waiting for a peer that may
|
|
// never say anything again, and the wg.Wait below would block forever.
|
|
//
|
|
// This was not theoretical. mavweb, mavpoll, mavcaldav and mavmaild all
|
|
// hold a long-lived connection open, so on 2026-08-01 mavend deadlocked on
|
|
// every single shutdown, never returned from run(), and never reached the
|
|
// `defer st.Close()` that seals the database. The deployed ciphertext was
|
|
// eleven days stale before anyone noticed.
|
|
connMu sync.Mutex
|
|
conns map[net.Conn]struct{}
|
|
|
|
// Check — optional authorization hook. dispatch runs it BEFORE method
|
|
// dispatch, with the raw params, so the auth layer can make verdicts
|
|
// that depend on the call's shape (e.g. WriteFact's source). A non-nil
|
|
// error aborts the call; the wire code is codeForbidden when the error
|
|
// satisfies errors.Is(ErrForbidden), else codeInternal.
|
|
//
|
|
// Nil ⇒ today's auth floor: any same-uid caller (the 0600 socket perms)
|
|
// is authorized, identical to pre-auth behavior. The daemon sets this to
|
|
// auth.Gate.Check once the auth layer is constructed; there is no module
|
|
// change to gain or lose the seam.
|
|
Check CheckFunc
|
|
|
|
// StepUp — optional handler for MethodAssertStepUp. When a real Session
|
|
// (PasskeySession) is wired, the daemon sets this to session.Assert so a
|
|
// module (mavweb) can assert a user-verification gesture over IPC. Nil ⇒
|
|
// MethodAssertStepUp returns ErrUnknownMethod (same as pre-stepup floor).
|
|
StepUp StepUpFunc
|
|
|
|
// WrapKeyFn — wraps the in-memory store encryption key under the passkey
|
|
// PRF secret (HKDF-AESGCM) and writes the wrapped blob to disk.
|
|
// Set by the daemon; nil ⇒ MethodStoreEncryptionKey returns ErrUnknownMethod.
|
|
WrapKeyFn WrapKeyFunc
|
|
|
|
// IngestMailFn — extracts task candidates from one fetched message. Set by
|
|
// the daemon only when an email block is configured AND there is a
|
|
// llama-server to extract with; nil ⇒ MethodIngestMail returns
|
|
// ErrUnknownMethod, so a mail reader pointed at a core that is not
|
|
// configured for mail is refused rather than silently ignored.
|
|
//
|
|
// Like StepUp/WrapKeyFn/UnlockFn this bypasses CoreAPI: it is not a store
|
|
// operation, it needs the resident model, and it must not become a method
|
|
// every CoreAPI implementation has to carry.
|
|
IngestMailFn IngestMailFunc
|
|
|
|
// SwapModelFn / ModelStatusFn — the on-the-fly resident model swap (Vikunja
|
|
// #250) and its read side. Set by the daemon only when phraser.swap_models
|
|
// lists at least one model AND the phraser owns a llama-server; nil ⇒ both
|
|
// methods answer ErrUnknownMethod, which is what "off unless configured"
|
|
// looks like at the wire.
|
|
//
|
|
// They bypass CoreAPI for the same reason IngestMailFn does: this is not a
|
|
// store operation, it needs the daemon's llama-server, and no other CoreAPI
|
|
// implementation should have to carry it. MethodSwapModel is AuthStepUp in
|
|
// internal/auth — owner-triggered, never an act and never a timer.
|
|
SwapModelFn SwapModelFunc
|
|
ModelStatusFn ModelStatusFunc
|
|
|
|
// DescribeImageFn — looks at one image (Vikunja #252). Set by the daemon
|
|
// whenever a media store is configured. Vision being off does not clear it:
|
|
// the image is stored and the reply says she cannot read it yet, which is
|
|
// re-runnable by id later. nil ⇒ no media block ⇒ MethodDescribeImage
|
|
// answers ErrUnknownMethod, so a surface cannot make Maven accept a photo
|
|
// by merely sending one.
|
|
//
|
|
// It bypasses CoreAPI for the same reason IngestMailFn does: it needs a blob
|
|
// store and a vision server, neither of which is a store operation, and no
|
|
// other CoreAPI implementation should have to carry it.
|
|
DescribeImageFn DescribeImageFunc
|
|
|
|
// Capture* — the meeting recorder (Vikunja #253). Set by the daemon only
|
|
// when a media store is configured AND capture.enabled is true; nil ⇒ all
|
|
// four methods answer ErrUnknownMethod. That is the load-bearing default for
|
|
// this capability: on an unconfigured box there is no wire path that begins a
|
|
// recording, so nothing can be recorded by accident, by a bug in a surface,
|
|
// or by a model deciding it would be helpful.
|
|
//
|
|
// They bypass CoreAPI because a recorder needs a blob store, an STT worker
|
|
// and a llama-server, none of which is a store operation.
|
|
CaptureStartFn CaptureStartFunc
|
|
CaptureAppendFn CaptureAppendFunc
|
|
CaptureStopFn CaptureStopFunc
|
|
CaptureStatusFn CaptureStatusFunc
|
|
|
|
// Speaker* — voice identification (Vikunja #255). Set by the daemon only
|
|
// when a speaker block is configured; nil ⇒ all three methods answer
|
|
// ErrUnknownMethod, so on an unconfigured box no wire path enrols a voice.
|
|
EnrollSpeakerFn EnrollSpeakerFunc
|
|
ListSpeakersFn ListSpeakersFunc
|
|
ForgetSpeakerFn ForgetSpeakerFunc
|
|
|
|
// LockedFn — reports whether the daemon is in locked (pre-unlock) mode.
|
|
// Read by MethodPing only. Nil ⇒ not locked, which is what an embedded or
|
|
// test Server without the unlock dance is.
|
|
LockedFn func() bool
|
|
|
|
// UnlockFn — unwraps the store encryption key from the wrapped blob using
|
|
// the passkey PRF secret, opens the encrypted store, and wires
|
|
// the rest of the daemon (voice, loop, delivery). Set by the daemon when
|
|
// in locked mode; nil ⇒ MethodUnlock returns ErrUnknownMethod.
|
|
UnlockFn UnlockFunc
|
|
|
|
// now is injected so tests can drive time; the loop already works in
|
|
// absolute ts supplied by callers, so this isn't load-bearing for live ops.
|
|
}
|
|
|
|
// WrapKeyFunc — wraps the store encryption key under the passkey-derived
|
|
// secret (a 32-byte WebAuthn PRF output) and persists the wrapped blob.
|
|
//
|
|
// explicit distinguishes "the operator asked for the cold-start key to be
|
|
// written" from "a passkey was asserted". Only the first may overwrite a blob
|
|
// that is already there; see cmd/mavend/keyfile.go.
|
|
type WrapKeyFunc func(ctx context.Context, secret []byte, explicit bool) error
|
|
|
|
// UnlockFunc — unwraps the store encryption key using the passkey-derived
|
|
// secret and completes daemon initialization.
|
|
type UnlockFunc func(ctx context.Context, secret []byte) error
|
|
|
|
// SwapModelFunc — loads another resident model in place of the live one.
|
|
type SwapModelFunc func(ctx context.Context, req SwapModelReq) (SwapModelResp, error)
|
|
|
|
// ModelStatusFunc — reports the resident model and the swap allowlist.
|
|
type ModelStatusFunc func(ctx context.Context) (ModelStatusResp, error)
|
|
|
|
// IngestMailFunc — core-side mail extraction. Returns what was captured.
|
|
type IngestMailFunc func(ctx context.Context, req IngestMailReq) (IngestMailResp, error)
|
|
|
|
// DescribeImageFunc — core-side image intake + description.
|
|
type DescribeImageFunc func(ctx context.Context, req DescribeImageReq) (DescribeImageResp, error)
|
|
|
|
// CaptureStartFunc / CaptureAppendFunc / CaptureStopFunc / CaptureStatusFunc —
|
|
// the four core-side halves of the meeting recorder.
|
|
type CaptureStartFunc func(ctx context.Context, req CaptureStartReq) (CaptureStartResp, error)
|
|
type CaptureAppendFunc func(ctx context.Context, req CaptureAppendReq) (CaptureAppendResp, error)
|
|
type CaptureStopFunc func(ctx context.Context, req CaptureStopReq) (CaptureStopResp, error)
|
|
type CaptureStatusFunc func(ctx context.Context) (CaptureStatusResp, error)
|
|
|
|
// EnrollSpeakerFunc / ListSpeakersFunc / ForgetSpeakerFunc — the core-side
|
|
// halves of voice enrolment.
|
|
type EnrollSpeakerFunc func(ctx context.Context, req EnrollSpeakerReq) (EnrollSpeakerResp, error)
|
|
type ListSpeakersFunc func(ctx context.Context) (ListSpeakersResp, error)
|
|
type ForgetSpeakerFunc func(ctx context.Context, req ForgetSpeakerReq) error
|
|
|
|
// CheckFunc — the auth hook signature. Wired by the daemon (auth.Gate.Check
|
|
// satisfies this); dispatch calls it once per request after param-unmarshal
|
|
// independence (it gets the raw params, may unmarshal what it needs — ipc
|
|
// already unmarshals for the typed call separately). Keeping Check on raw
|
|
// params means ipc doesn't need to know each method's authority shape, and
|
|
// auth doesn't need to leak implementation into ipc.
|
|
type CheckFunc func(ctx context.Context, m Method, params json.RawMessage) error
|
|
|
|
// StepUpFunc — records a user-verification gesture. Set by the daemon when
|
|
// a real Session is wired (PasskeySession); nil means not available.
|
|
// MethodAssertStepUp dispatch calls this instead of going through CoreAPI.
|
|
type StepUpFunc func(ctx context.Context) error
|
|
|
|
// Listen creates a Server bound to path.
|
|
//
|
|
// A bare path is a unix socket, unchanged: its parent dir is 0700 and the
|
|
// socket file itself is 0600, so only the same unix user can connect — the
|
|
// current "auth floor", same radius as wg at the network boundary. A stale
|
|
// socket is removed first so the daemon restarts cleanly.
|
|
//
|
|
// A "tcp://host:port?token=..." address binds a network listener instead, for
|
|
// a module that lives on another host. There is no filesystem there to be the
|
|
// auth floor, so netaddr checks the shared token before this package sees the
|
|
// connection and a token is mandatory. See internal/netaddr.
|
|
func Listen(path string, api CoreAPI) (*Server, error) {
|
|
addr, err := netaddr.Parse(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ln, err := netaddr.Listen(addr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
s := &Server{
|
|
path: path,
|
|
addr: addr,
|
|
ln: ln,
|
|
done: make(chan struct{}),
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
}
|
|
s.api.Store(api)
|
|
return s, nil
|
|
}
|
|
|
|
// Serve accepts connections until the listener closes. Each connection is
|
|
// served in its own goroutine; a panicking handler or a malformed frame tears
|
|
// down only that conn, not the server (a misbehaving module can't kill core).
|
|
func (s *Server) Serve() error {
|
|
for {
|
|
c, err := s.ln.Accept()
|
|
if err != nil {
|
|
select {
|
|
case <-s.done:
|
|
return nil // graceful Close
|
|
default:
|
|
return fmt.Errorf("ipc: accept: %w", err)
|
|
}
|
|
}
|
|
// wg.Add under accept mutex so Close's wg.Wait (also under accept) sees
|
|
// a consistent counter — a connection accepted just before Close closes
|
|
// the listener must be tracked before Wait starts.
|
|
s.accept.Lock()
|
|
s.wg.Add(1)
|
|
s.accept.Unlock()
|
|
s.trackConn(c)
|
|
go func(c net.Conn) {
|
|
defer s.wg.Done()
|
|
defer s.untrackConn(c)
|
|
defer c.Close()
|
|
s.serveConn(c)
|
|
}(c)
|
|
}
|
|
}
|
|
|
|
func (s *Server) serveConn(c net.Conn) {
|
|
caller, callerOK := peerCaller(c)
|
|
// Derived from the server's, so Close cancels a dispatch in flight, and
|
|
// cancelled when this conn ends so nothing a handler spawned outlives it.
|
|
ctx, cancel := context.WithCancel(s.serverContext())
|
|
defer cancel()
|
|
if callerOK {
|
|
ctx = WithCaller(ctx, caller)
|
|
}
|
|
for {
|
|
var req Request
|
|
if err := readFrame(c, &req); err != nil {
|
|
return // EOF or malformed ⇒ end this conn; nothing to recover
|
|
}
|
|
// redispatch expects the framework's recover so one bad call can't
|
|
// take the goroutine (and therefore the conn) with it.
|
|
result, err := s.safeDispatch(ctx, req)
|
|
resp := Response{}
|
|
if err != nil {
|
|
resp.Error = rpcErr(err)
|
|
} else {
|
|
resp.Result = result
|
|
}
|
|
if err := writeFrame(c, resp); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// serverContext is s.ctx, or Background for a Server built as a zero value
|
|
// rather than by Listen (the wiring tests do that).
|
|
func (s *Server) serverContext() context.Context {
|
|
if s.ctx == nil {
|
|
return context.Background()
|
|
}
|
|
return s.ctx
|
|
}
|
|
|
|
func (s *Server) safeDispatch(ctx context.Context, req Request) (result json.RawMessage, err error) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
err = fmt.Errorf("ipc: panic dispatching %s: %v", req.Method, r)
|
|
}
|
|
}()
|
|
return s.dispatch(ctx, req)
|
|
}
|
|
|
|
// handlerFunc — one table entry's shape: unmarshal req.Params (if it wants
|
|
// any), call the matching CoreAPI method against the api passed in, marshal
|
|
// the result. api is a parameter, not a closed-over field, precisely so a
|
|
// table built once at package init never pins a stale CoreAPI — see the note
|
|
// on methodTable below about SetAPI.
|
|
type handlerFunc func(ctx context.Context, api CoreAPI, raw json.RawMessage) (json.RawMessage, error)
|
|
|
|
// withParams adapts a (typed params, typed result) CoreAPI call into a
|
|
// handlerFunc: unmarshal into P, call fn, marshal R. On error the result is
|
|
// dropped (marshalResult's output is never read when err != nil — see
|
|
// serveConn) so every entry can uniformly return early on error without
|
|
// re-deriving what the pre-table per-arm code used to return in that case.
|
|
func withParams[P any, R any](fn func(ctx context.Context, api CoreAPI, p P) (R, error)) handlerFunc {
|
|
return func(ctx context.Context, api CoreAPI, raw json.RawMessage) (json.RawMessage, error) {
|
|
var p P
|
|
if err := unmarshalParams(raw, &p); err != nil {
|
|
return nil, err
|
|
}
|
|
r, err := fn(ctx, api, p)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return marshalResult(r), nil
|
|
}
|
|
}
|
|
|
|
// withParamsVoid is withParams for the error-only methods (mark/resolve/
|
|
// enable/disable/...): params in, no result out, wire reply is always null.
|
|
func withParamsVoid[P any](fn func(ctx context.Context, api CoreAPI, p P) error) handlerFunc {
|
|
return func(ctx context.Context, api CoreAPI, raw json.RawMessage) (json.RawMessage, error) {
|
|
var p P
|
|
if err := unmarshalParams(raw, &p); err != nil {
|
|
return nil, err
|
|
}
|
|
return marshalResult(nil), fn(ctx, api, p)
|
|
}
|
|
}
|
|
|
|
// withoutParams is withParams for the handful of methods that take no
|
|
// params at all (Presence, TickTrace, MorningStatus, ListProposedRoutines).
|
|
// It does NOT call unmarshalParams — matching the pre-table arms, which
|
|
// never touched req.Params for these four methods.
|
|
func withoutParams[R any](fn func(ctx context.Context, api CoreAPI) (R, error)) handlerFunc {
|
|
return func(ctx context.Context, api CoreAPI, _ json.RawMessage) (json.RawMessage, error) {
|
|
r, err := fn(ctx, api)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return marshalResult(r), nil
|
|
}
|
|
}
|
|
|
|
// withParamsSlice is withParams for a list read. It replaces a nil slice with
|
|
// an empty one so the wire carries [] rather than null, which every reader of
|
|
// these methods relies on.
|
|
func withParamsSlice[P any, E any](fn func(ctx context.Context, api CoreAPI, p P) ([]E, error)) handlerFunc {
|
|
return withParams(func(ctx context.Context, api CoreAPI, p P) ([]E, error) {
|
|
out, err := fn(ctx, api, p)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if out == nil {
|
|
out = []E{}
|
|
}
|
|
return out, nil
|
|
})
|
|
}
|
|
|
|
// methodTable — one entry per CoreAPI-backed method. Built once at package
|
|
// init, not per-Server and not per-dispatch: entries close over nothing but
|
|
// the CoreAPI method being called, and dispatch passes in the *current*
|
|
// api (loaded fresh via s.api.Load() every call, same as before the table
|
|
// existed) as an argument — so SetAPI's runtime swap (the unlock transition)
|
|
// is still honored on the very next request with no extra plumbing here.
|
|
//
|
|
// MethodAssertStepUp, MethodStoreEncryptionKey, MethodUnlock,
|
|
// MethodIngestMail, MethodSwapModel, MethodModelStatus,
|
|
// MethodDescribeImage and the four MethodCapture* methods are NOT in this
|
|
// table: they bypass CoreAPI entirely (s.StepUp / s.WrapKeyFn / s.UnlockFn /
|
|
// s.IngestMailFn / s.DescribeImageFn / s.Capture*Fn), so dispatch
|
|
// special-cases them before consulting the table.
|
|
var methodTable = map[Method]handlerFunc{
|
|
MethodWriteFact: withParams(func(ctx context.Context, api CoreAPI, p WriteFactReq) (idResp, error) {
|
|
id, err := api.WriteFact(ctx, p)
|
|
return idResp{ID: id}, err
|
|
}),
|
|
MethodLatestFact: withParams(func(ctx context.Context, api CoreAPI, p keyReq) (Fact, error) {
|
|
return api.LatestFact(ctx, p.Key)
|
|
}),
|
|
MethodLatestFactBySource: withParams(func(ctx context.Context, api CoreAPI, p keySourceReq) (Fact, error) {
|
|
return api.LatestFactBySource(ctx, p.Key, p.Source)
|
|
}),
|
|
MethodSince: withParams(func(ctx context.Context, api CoreAPI, p sinceReq) (sinceResp, error) {
|
|
d, err := api.Since(ctx, p.Key, p.Now)
|
|
return sinceResp{Dur: d}, err
|
|
}),
|
|
MethodPresence: withoutParams(func(ctx context.Context, api CoreAPI) (Presence, error) {
|
|
return api.Presence(ctx)
|
|
}),
|
|
MethodCreateReminder: withParams(func(ctx context.Context, api CoreAPI, p createReminderReq) (idResp, error) {
|
|
id, err := api.CreateReminder(ctx, p.Fire, p.Payload, p.Cron)
|
|
return idResp{ID: id}, err
|
|
}),
|
|
MethodMarkReminder: withParamsVoid(func(ctx context.Context, api CoreAPI, p markReminderReq) error {
|
|
return api.MarkReminder(ctx, p.ID, p.Status)
|
|
}),
|
|
MethodListReminders: withParamsSlice(func(ctx context.Context, api CoreAPI, p nReq) ([]Reminder, error) {
|
|
return api.ListReminders(ctx, p.N)
|
|
}),
|
|
MethodRecordNudge: withParams(func(ctx context.Context, api CoreAPI, p recordNudgeReq) (idResp, error) {
|
|
id, err := api.RecordNudge(ctx, p.Rule, p.Channel, p.Message, p.Ts)
|
|
return idResp{ID: id}, err
|
|
}),
|
|
MethodResolveNudge: withParamsVoid(func(ctx context.Context, api CoreAPI, p resolveNudgeReq) error {
|
|
return api.ResolveNudge(ctx, p.ID, p.Outcome, p.Ts)
|
|
}),
|
|
MethodRecentOutcomes: withParamsSlice(func(ctx context.Context, api CoreAPI, p outcomesReq) ([]string, error) {
|
|
return api.RecentOutcomes(ctx, p.Rule, p.N)
|
|
}),
|
|
MethodRecentFacts: withParamsSlice(func(ctx context.Context, api CoreAPI, p nReq) ([]Fact, error) {
|
|
return api.RecentFacts(ctx, p.N)
|
|
}),
|
|
MethodRecentActiveFacts: withParamsSlice(func(ctx context.Context, api CoreAPI, p kindNReq) ([]Fact, error) {
|
|
return api.RecentActiveFactsByKind(ctx, p.Kind, p.N)
|
|
}),
|
|
MethodCalendarEvents: withParamsSlice(func(ctx context.Context, api CoreAPI, p calendarEventsReq) ([]Fact, error) {
|
|
return api.CalendarEvents(ctx, p.From, p.To)
|
|
}),
|
|
MethodRecentEcoTraces: withParamsSlice(func(ctx context.Context, api CoreAPI, p nReq) ([]EcosystemTrace, error) {
|
|
return api.RecentEcosystemTraces(ctx, p.N)
|
|
}),
|
|
MethodDeliveryAttempts: withParamsSlice(func(ctx context.Context, api CoreAPI, p deliveryAttemptsReq) ([]DeliveryAttempt, error) {
|
|
return api.DeliveryAttempts(ctx, p.Status, p.N)
|
|
}),
|
|
MethodRecentNudges: withParamsSlice(func(ctx context.Context, api CoreAPI, p nReq) ([]Nudge, error) {
|
|
return api.RecentNudges(ctx, p.N)
|
|
}),
|
|
MethodWriteNote: withParams(func(ctx context.Context, api CoreAPI, p writeNoteReq) (idResp, error) {
|
|
id, err := api.WriteNote(ctx, p.Ts, p.Text, p.Embedding, p.Source)
|
|
return idResp{ID: id}, err
|
|
}),
|
|
MethodQueryNotes: withParamsSlice(func(ctx context.Context, api CoreAPI, p queryNotesReq) ([]Note, error) {
|
|
return api.QueryNotes(ctx, p.Embedding, p.K)
|
|
}),
|
|
MethodRecentNotesFromSource: withParamsSlice(func(ctx context.Context, api CoreAPI, p sourceNReq) ([]Note, error) {
|
|
return api.RecentNotesFromSource(ctx, p.Prefix, p.N)
|
|
}),
|
|
MethodRecentNotes: withParamsSlice(func(ctx context.Context, api CoreAPI, p nReq) ([]Note, error) {
|
|
return api.RecentNotes(ctx, p.N)
|
|
}),
|
|
MethodProposeTool: withParams(func(ctx context.Context, api CoreAPI, p proposeToolReq) (proposeToolResp, error) {
|
|
ok, err := api.ProposeTool(ctx, p.Name, p.Utterance, p.Scope, p.Ts)
|
|
return proposeToolResp{Proposed: ok}, err
|
|
}),
|
|
MethodEnableTool: withParamsVoid(func(ctx context.Context, api CoreAPI, p enableToolReq) error {
|
|
return api.EnableTool(ctx, p.Name, p.Cmd, p.Destructive, p.Scope, p.Ts)
|
|
}),
|
|
MethodDisableTool: withParamsVoid(func(ctx context.Context, api CoreAPI, p disableToolReq) error {
|
|
return api.DisableTool(ctx, p.Name)
|
|
}),
|
|
MethodLookupTool: withParams(func(ctx context.Context, api CoreAPI, p lookupToolReq) (Tool, error) {
|
|
return api.LookupTool(ctx, p.Name)
|
|
}),
|
|
MethodListTools: withParams(func(ctx context.Context, api CoreAPI, p listToolsReq) (listToolsResp, error) {
|
|
out, err := api.ListTools(ctx, p.Status)
|
|
if err != nil {
|
|
return listToolsResp{}, err
|
|
}
|
|
if out == nil {
|
|
out = []Tool{}
|
|
}
|
|
return listToolsResp{Tools: out}, nil
|
|
}),
|
|
// MethodDeleteTool shares disableToolReq — both take just a tool name.
|
|
MethodDeleteTool: withParamsVoid(func(ctx context.Context, api CoreAPI, p disableToolReq) error {
|
|
return api.DeleteTool(ctx, p.Name)
|
|
}),
|
|
MethodCaptureTask: withParams(func(ctx context.Context, api CoreAPI, p CaptureTaskReq) (CaptureTaskResp, error) {
|
|
return api.CaptureTask(ctx, p)
|
|
}),
|
|
MethodSeedEvent: withParams(func(ctx context.Context, api CoreAPI, p SeedEventReq) (SeedEventResp, error) {
|
|
return api.SeedEvent(ctx, p)
|
|
}),
|
|
MethodListTasks: withParams(func(ctx context.Context, api CoreAPI, p listTasksReq) (listTasksResp, error) {
|
|
out, err := api.ListTasks(ctx, p.Status)
|
|
if err != nil {
|
|
return listTasksResp{}, err
|
|
}
|
|
if out == nil {
|
|
out = []Task{}
|
|
}
|
|
return listTasksResp{Tasks: out}, nil
|
|
}),
|
|
MethodSetTaskStatus: withParamsVoid(func(ctx context.Context, api CoreAPI, p setTaskStatusReq) error {
|
|
return api.SetTaskStatus(ctx, p.ID, p.Status, p.Ts, p.By)
|
|
}),
|
|
MethodResolveEntity: withParams(func(ctx context.Context, api CoreAPI, p resolveEntityReq) (resolveEntityResp, error) {
|
|
ref, err := api.ResolveEntity(ctx, p.Query, p.Types)
|
|
if err != nil {
|
|
return resolveEntityResp{}, err
|
|
}
|
|
return resolveEntityResp{Ref: ref}, nil
|
|
}),
|
|
MethodEditTask: withParamsVoid(func(ctx context.Context, api CoreAPI, p editTaskReq) error {
|
|
return api.EditTask(ctx, p.ID, p.Text, p.Due, p.Weight)
|
|
}),
|
|
MethodSetTaskFields: withParamsVoid(func(ctx context.Context, api CoreAPI, p setTaskFieldsReq) error {
|
|
return api.SetTaskFields(ctx, p.ID, p.DoneWhen, p.BlockedOn)
|
|
}),
|
|
MethodListProposedRoutines: withoutParams(func(ctx context.Context, api CoreAPI) (listProposedRoutinesResp, error) {
|
|
out, err := api.ListProposedRoutines(ctx)
|
|
if err != nil {
|
|
return listProposedRoutinesResp{}, err
|
|
}
|
|
if out == nil {
|
|
out = []ProposedRoutine{}
|
|
}
|
|
return listProposedRoutinesResp{Routines: out}, nil
|
|
}),
|
|
MethodDismissProposedRoutine: withParamsVoid(func(ctx context.Context, api CoreAPI, p idReq) error {
|
|
return api.DismissProposedRoutine(ctx, p.ID)
|
|
}),
|
|
MethodAcceptProposedRoutine: withParamsVoid(func(ctx context.Context, api CoreAPI, p idReq) error {
|
|
return api.AcceptProposedRoutine(ctx, p.ID)
|
|
}),
|
|
MethodRevertFact: withParams(func(ctx context.Context, api CoreAPI, p revertReq) (revertResp, error) {
|
|
newID, err := api.RevertFact(ctx, p.Key)
|
|
return revertResp{NewID: newID}, err
|
|
}),
|
|
MethodChat: withParams(func(ctx context.Context, api CoreAPI, p chatReq) (chatResp, error) {
|
|
reply, err := api.Chat(ctx, p.Conversation, p.Text)
|
|
return chatResp{Reply: reply.Reply, Source: reply.Source, TraceID: reply.TraceID}, err
|
|
}),
|
|
MethodCorrectTurn: withParams(func(ctx context.Context, api CoreAPI, p correctTurnReq) (struct{}, error) {
|
|
return struct{}{}, api.CorrectTurn(ctx, p.TraceID, p.ShouldBe)
|
|
}),
|
|
MethodTickTrace: withoutParams(func(ctx context.Context, api CoreAPI) (TickTrace, error) {
|
|
return api.TickTrace(ctx)
|
|
}),
|
|
MethodTurnDecisions: withParams(func(ctx context.Context, api CoreAPI, p nReq) ([]TurnDecision, error) {
|
|
d, err := api.TurnDecisions(ctx, p.N)
|
|
if d == nil {
|
|
d = []TurnDecision{}
|
|
}
|
|
return d, err
|
|
}),
|
|
// MorningStatus intentionally has no nil→[]T{} normalization here — the
|
|
// pre-table arm marshaled api.MorningStatus's result as-is (a nil slice
|
|
// serializes as JSON null), and this preserves that exact wire shape.
|
|
MethodDayPlan: withoutParams(func(ctx context.Context, api CoreAPI) (DayPlan, error) {
|
|
return api.DayPlan(ctx)
|
|
}),
|
|
MethodMorningStatus: withoutParams(func(ctx context.Context, api CoreAPI) ([]MorningRoutineStatus, error) {
|
|
return api.MorningStatus(ctx)
|
|
}),
|
|
MethodRecentEvents: withParamsSlice(func(ctx context.Context, api CoreAPI, p nReq) ([]IntakeEvent, error) {
|
|
return api.RecentEvents(ctx, p.N)
|
|
}),
|
|
MethodMCPServers: withoutParams(func(ctx context.Context, api CoreAPI) ([]MCPServerStatus, error) {
|
|
out, err := api.MCPServers(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if out == nil {
|
|
out = []MCPServerStatus{}
|
|
}
|
|
return out, nil
|
|
}),
|
|
}
|
|
|
|
// dispatch unmarshals params for req.Method and calls the matching CoreAPI
|
|
// method. Unknown method ⇒ ErrUnknownMethod; a malformed params payload ⇒
|
|
// ErrBadParams with the underlying text (local, server-side, not shipped to
|
|
// the module except as a generic message via rpcErr).
|
|
//
|
|
// Authorization runs ONCE at the top: if Server.Check is set, we call it with
|
|
// the raw params before any method-specific unmarshal; auth unmarshals fields
|
|
// it cares about (WriteFact's source, etc.) itself. A nil Check is the floor
|
|
// and is invisible at the wire — pre-auth Server behavior is unchanged.
|
|
func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, error) {
|
|
api := s.api.Load().(CoreAPI)
|
|
if s.Check != nil {
|
|
if err := s.Check(ctx, req.Method, req.Params); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// These bypass CoreAPI entirely — they drive Server fields set
|
|
// directly by the daemon (StepUp / WrapKeyFn / UnlockFn), not store
|
|
// state, so they can never be table entries keyed on a CoreAPI method.
|
|
switch req.Method {
|
|
case MethodPing:
|
|
// Deliberately reaches nothing: no store, no CoreAPI, no daemon
|
|
// component. That is what makes it answerable in locked mode, and it is
|
|
// the whole point — an update that restarts her into locked mode has to
|
|
// be able to tell that apart from a daemon that did not come up.
|
|
locked := false
|
|
if s.LockedFn != nil {
|
|
locked = s.LockedFn()
|
|
}
|
|
return marshalResult(PingResp{Alive: true, Locked: locked}), nil
|
|
|
|
case MethodAssertStepUp:
|
|
if s.StepUp == nil {
|
|
return nil, unknownMethod(req.Method)
|
|
}
|
|
return marshalResult(nil), s.StepUp(ctx)
|
|
|
|
case MethodStoreEncryptionKey:
|
|
if s.WrapKeyFn == nil {
|
|
return nil, unknownMethod(req.Method)
|
|
}
|
|
var p storeEncryptionKeyReq
|
|
if err := unmarshalParams(req.Params, &p); err != nil {
|
|
return nil, err
|
|
}
|
|
return marshalResult(nil), s.WrapKeyFn(ctx, p.Secret, p.Explicit)
|
|
|
|
case MethodUnlock:
|
|
if s.UnlockFn == nil {
|
|
return nil, unknownMethod(req.Method)
|
|
}
|
|
var p unlockReq
|
|
if err := unmarshalParams(req.Params, &p); err != nil {
|
|
return nil, err
|
|
}
|
|
return marshalResult(nil), s.UnlockFn(ctx, p.Secret)
|
|
|
|
case MethodIngestMail:
|
|
return callDirect(ctx, req, s.IngestMailFn)
|
|
case MethodSwapModel:
|
|
return callDirect(ctx, req, s.SwapModelFn)
|
|
case MethodDescribeImage:
|
|
return callDirect(ctx, req, s.DescribeImageFn)
|
|
case MethodCaptureStart:
|
|
return callDirect(ctx, req, s.CaptureStartFn)
|
|
case MethodCaptureAppend:
|
|
return callDirect(ctx, req, s.CaptureAppendFn)
|
|
case MethodCaptureStop:
|
|
return callDirect(ctx, req, s.CaptureStopFn)
|
|
case MethodEnrollSpeaker:
|
|
return callDirect(ctx, req, s.EnrollSpeakerFn)
|
|
case MethodCaptureStatus:
|
|
return callDirectNoParams(ctx, req, s.CaptureStatusFn)
|
|
case MethodListSpeakers:
|
|
return callDirectNoParams(ctx, req, s.ListSpeakersFn)
|
|
case MethodModelStatus:
|
|
return callDirectNoParams(ctx, req, s.ModelStatusFn)
|
|
case MethodForgetSpeaker:
|
|
return callDirectVoid(ctx, req, s.ForgetSpeakerFn)
|
|
}
|
|
|
|
h, ok := methodTable[req.Method]
|
|
if !ok {
|
|
return nil, unknownMethod(req.Method)
|
|
}
|
|
return h(ctx, api, req.Params)
|
|
}
|
|
|
|
// callDirect runs a daemon-supplied handler that bypasses CoreAPI: unmarshal
|
|
// the params, call it, marshal the reply. A nil handler is the capability being
|
|
// unconfigured on this box, and the wire says so as an unknown method.
|
|
func callDirect[P any, R any](ctx context.Context, req Request, fn func(context.Context, P) (R, error)) (json.RawMessage, error) {
|
|
if fn == nil {
|
|
return nil, unknownMethod(req.Method)
|
|
}
|
|
var p P
|
|
if err := unmarshalParams(req.Params, &p); err != nil {
|
|
return nil, err
|
|
}
|
|
r, err := fn(ctx, p)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return marshalResult(r), nil
|
|
}
|
|
|
|
// callDirectNoParams is callDirect for a handler that reads no params. Like
|
|
// withoutParams it never touches req.Params.
|
|
func callDirectNoParams[R any](ctx context.Context, req Request, fn func(context.Context) (R, error)) (json.RawMessage, error) {
|
|
if fn == nil {
|
|
return nil, unknownMethod(req.Method)
|
|
}
|
|
r, err := fn(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return marshalResult(r), nil
|
|
}
|
|
|
|
// callDirectVoid is callDirect for a handler with nothing to report back. The
|
|
// wire reply is always null.
|
|
func callDirectVoid[P any](ctx context.Context, req Request, fn func(context.Context, P) error) (json.RawMessage, error) {
|
|
if fn == nil {
|
|
return nil, unknownMethod(req.Method)
|
|
}
|
|
var p P
|
|
if err := unmarshalParams(req.Params, &p); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := fn(ctx, p); err != nil {
|
|
return nil, err
|
|
}
|
|
return marshalResult(nil), nil
|
|
}
|
|
|
|
func unknownMethod(m Method) error {
|
|
return fmt.Errorf("%w: %s", ErrUnknownMethod, m)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// Close stops accepting and waits for in-flight connections to drain. The
|
|
// socket file is removed so a restart can rebind cleanly. Idempotent.
|
|
func (s *Server) Close() error {
|
|
select {
|
|
case <-s.done:
|
|
return nil
|
|
default:
|
|
close(s.done)
|
|
}
|
|
if s.cancel != nil {
|
|
s.cancel()
|
|
}
|
|
err := s.ln.Close()
|
|
// Closing the listener stops new connections; it does nothing to the ones
|
|
// already accepted. Close those too, or every serveConn parked in readFrame
|
|
// waits on a peer that has no reason to hang up and the Wait below never
|
|
// returns. See the comment on Server.conns.
|
|
s.closeConns()
|
|
// Under accept lock: after the listener closes, no new Accept can complete,
|
|
// so no new wg.Add will be called. The Wait is safe to observe the wg
|
|
// counter because any in-flight Accept that already got a conn either
|
|
// already called wg.Add (before releasing the lock) or will see the closed
|
|
// listener error and not call wg.Add at all.
|
|
s.accept.Lock()
|
|
waited := waitTimeout(&s.wg, closeGrace)
|
|
s.accept.Unlock()
|
|
if !waited {
|
|
// Bounded on purpose. A dispatch can be mid-call into the resident
|
|
// model, which has its own timeout measured in tens of seconds, and the
|
|
// caller of Close is on its way to sealing the database with whatever
|
|
// grace the supervisor allows. Abandoning one in-flight RPC is cheap;
|
|
// missing the seal costs every write since the last clean shutdown.
|
|
log.Printf("ipc: %d connection(s) still busy after %s, closing anyway", s.liveConns(), closeGrace)
|
|
}
|
|
netaddr.Cleanup(s.addr)
|
|
return err
|
|
}
|
|
|
|
// closeGrace — how long Close waits for in-flight dispatches to finish before
|
|
// giving up on them. Well inside the ten seconds docker allows by default, so
|
|
// the caller still has time to seal.
|
|
const closeGrace = 3 * time.Second
|
|
|
|
func (s *Server) trackConn(c net.Conn) {
|
|
s.connMu.Lock()
|
|
defer s.connMu.Unlock()
|
|
if s.conns == nil {
|
|
s.conns = make(map[net.Conn]struct{})
|
|
}
|
|
s.conns[c] = struct{}{}
|
|
}
|
|
|
|
func (s *Server) untrackConn(c net.Conn) {
|
|
s.connMu.Lock()
|
|
defer s.connMu.Unlock()
|
|
delete(s.conns, c)
|
|
}
|
|
|
|
func (s *Server) liveConns() int {
|
|
s.connMu.Lock()
|
|
defer s.connMu.Unlock()
|
|
return len(s.conns)
|
|
}
|
|
|
|
// closeConns closes every live connection, which is what unblocks the reads.
|
|
// The serveConn goroutines see the resulting error and return.
|
|
func (s *Server) closeConns() {
|
|
s.connMu.Lock()
|
|
live := make([]net.Conn, 0, len(s.conns))
|
|
for c := range s.conns {
|
|
live = append(live, c)
|
|
}
|
|
s.connMu.Unlock()
|
|
for _, c := range live {
|
|
_ = c.Close()
|
|
}
|
|
}
|
|
|
|
// waitTimeout waits on wg for at most d, reporting whether it finished. The
|
|
// abandoned goroutines are still holding a wg count, so nothing may reuse the
|
|
// WaitGroup afterwards — Close is terminal, which is what makes this safe.
|
|
func waitTimeout(wg *sync.WaitGroup, d time.Duration) bool {
|
|
done := make(chan struct{})
|
|
go func() {
|
|
wg.Wait()
|
|
close(done)
|
|
}()
|
|
select {
|
|
case <-done:
|
|
return true
|
|
case <-time.After(d):
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Path returns the filesystem path of the listening socket.
|
|
func (s *Server) Path() string { return s.path }
|
|
|
|
// SetAPI atomically replaces the CoreAPI the server dispatches to. Used by
|
|
// the daemon's unlock path: in locked mode a dummy API returns errors for all
|
|
// store methods; after unlock, the real store API is swapped in. Safe to call
|
|
// while the server is serving (dispatch loads api once per request via atomic).
|
|
func (s *Server) SetAPI(api CoreAPI) { s.api.Store(api) }
|
|
|
|
// peerCaller — read SO_PEERCRED off a unix conn to identify the connecting
|
|
// process. Returns ok=false on a non-unix conn or a platform without
|
|
// SO_PEERCRED; the caller then proceeds without a Caller (the socket perms
|
|
// already proved same-user). Linux only today; on other platforms this floors
|
|
// to "unknown caller" rather than failing — the wire still works.
|
|
func peerCaller(c net.Conn) (Caller, bool) {
|
|
uc, ok := c.(*net.UnixConn)
|
|
if !ok {
|
|
return Caller{}, false
|
|
}
|
|
raw, err := uc.SyscallConn()
|
|
if err != nil {
|
|
return Caller{}, false
|
|
}
|
|
var cred *unix.Ucred
|
|
ctrlErr := raw.Control(func(fd uintptr) {
|
|
cred, err = unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED)
|
|
})
|
|
if ctrlErr != nil || err != nil || cred == nil {
|
|
return Caller{}, false
|
|
}
|
|
return Caller{Uid: int32(cred.Uid), Pid: int32(cred.Pid)}, true
|
|
}
|