mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-19 16:20:38 +02:00
* feat(daemon): make workspace sync event-driven Co-authored-by: multica-agent <github@multica.ai> * fix(daemon): preserve trailing workspace changes Co-authored-by: multica-agent <github@multica.ai> * fix(workspace): reconcile failed creates Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai>
874 lines
25 KiB
Go
874 lines
25 KiB
Go
package daemonws
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
"github.com/multica-ai/multica/server/pkg/protocol"
|
|
)
|
|
|
|
const (
|
|
writeWait = 10 * time.Second
|
|
pongWait = 60 * time.Second
|
|
pingPeriod = (pongWait * 9) / 10
|
|
)
|
|
|
|
// ClientIdentity captures the already-authenticated daemon connection scope.
|
|
type ClientIdentity struct {
|
|
DaemonID string
|
|
UserID string
|
|
// WorkspaceID is the legacy single-workspace scope used by older callers
|
|
// and daemon-token auth. New code should populate WorkspaceIDs from the
|
|
// runtime rows authorized for this connection.
|
|
WorkspaceID string
|
|
WorkspaceIDs []string
|
|
RuntimeIDs []string
|
|
ClientVersion string
|
|
// Capabilities is the raw X-Client-Capabilities header captured at connect,
|
|
// so RPC handlers can honor the same capability gating as the HTTP path.
|
|
Capabilities string
|
|
}
|
|
|
|
// AuthorizedWorkspaceIDs returns the connection's workspace scope in stable
|
|
// order, preferring the multi-workspace field and falling back to WorkspaceID
|
|
// for older tests/callers.
|
|
func (i ClientIdentity) AuthorizedWorkspaceIDs() []string {
|
|
seen := make(map[string]struct{}, len(i.WorkspaceIDs)+1)
|
|
out := make([]string, 0, len(i.WorkspaceIDs)+1)
|
|
add := func(id string) {
|
|
id = strings.TrimSpace(id)
|
|
if id == "" {
|
|
return
|
|
}
|
|
if _, ok := seen[id]; ok {
|
|
return
|
|
}
|
|
seen[id] = struct{}{}
|
|
out = append(out, id)
|
|
}
|
|
for _, id := range i.WorkspaceIDs {
|
|
add(id)
|
|
}
|
|
if len(out) == 0 {
|
|
add(i.WorkspaceID)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (i ClientIdentity) PrimaryWorkspaceID() string {
|
|
ids := i.AuthorizedWorkspaceIDs()
|
|
if len(ids) == 0 {
|
|
return ""
|
|
}
|
|
return ids[0]
|
|
}
|
|
|
|
// AllowsWorkspace reports whether workspaceID is within the connection scope.
|
|
// An empty scope remains permissive for legacy unit tests that construct
|
|
// ClientIdentity directly without workspace data.
|
|
func (i ClientIdentity) AllowsWorkspace(workspaceID string) bool {
|
|
ids := i.AuthorizedWorkspaceIDs()
|
|
if len(ids) == 0 {
|
|
return true
|
|
}
|
|
for _, id := range ids {
|
|
if id == workspaceID {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
type client struct {
|
|
hub *Hub
|
|
conn *websocket.Conn
|
|
send chan []byte
|
|
identity ClientIdentity
|
|
runtimes map[string]struct{}
|
|
|
|
// ctx is cancelled when the connection tears down, so async RPC handlers
|
|
// stop instead of running against a dead socket. cancel is invoked from
|
|
// readPump's defer.
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
|
|
// sendMu guards sendClosed so a late async send (e.g. an RPC response
|
|
// goroutine) can never write to the closed send channel. teardown flips
|
|
// sendClosed under sendMu before closing send.
|
|
sendMu sync.Mutex
|
|
sendClosed bool
|
|
|
|
dedupMu sync.Mutex
|
|
seenIDs map[string]struct{}
|
|
seenList []string
|
|
|
|
// rpcSem bounds concurrent RPC handlers for this connection.
|
|
rpcSem chan struct{}
|
|
}
|
|
|
|
// trySend delivers frame to the write pump without blocking and without ever
|
|
// writing to a closed channel (safe against concurrent teardown). Returns false
|
|
// when the buffer is full or the connection is closing.
|
|
func (c *client) trySend(frame []byte) bool {
|
|
c.sendMu.Lock()
|
|
defer c.sendMu.Unlock()
|
|
if c.sendClosed {
|
|
return false
|
|
}
|
|
select {
|
|
case c.send <- frame:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
const eventDedupCapacity = 128
|
|
|
|
// markSeen records eventID as already delivered to this client. Empty event IDs
|
|
// disable dedup and are always delivered.
|
|
func (c *client) markSeen(eventID string) bool {
|
|
if eventID == "" {
|
|
return true
|
|
}
|
|
c.dedupMu.Lock()
|
|
defer c.dedupMu.Unlock()
|
|
if c.seenIDs == nil {
|
|
c.seenIDs = make(map[string]struct{}, eventDedupCapacity)
|
|
}
|
|
if _, ok := c.seenIDs[eventID]; ok {
|
|
return false
|
|
}
|
|
c.seenIDs[eventID] = struct{}{}
|
|
c.seenList = append(c.seenList, eventID)
|
|
if len(c.seenList) > eventDedupCapacity {
|
|
drop := c.seenList[0]
|
|
c.seenList = c.seenList[1:]
|
|
delete(c.seenIDs, drop)
|
|
}
|
|
return true
|
|
}
|
|
|
|
// HeartbeatHandler processes a daemon:heartbeat frame. It must verify that
|
|
// runtimeID is one of identity.RuntimeIDs (the connection's authenticated
|
|
// scope) and return the ack payload to send back. Returning an error skips
|
|
// the ack and is logged at debug level.
|
|
type HeartbeatHandler func(ctx context.Context, identity ClientIdentity, runtimeID string, supportsBatchImport bool) (*protocol.DaemonHeartbeatAckPayload, error)
|
|
|
|
// RPCHandler processes a generic daemon:rpc_request (MUL-4257). It dispatches
|
|
// on method (e.g. "tasks.claim"), scoping work to identity (DaemonID +
|
|
// authenticated RuntimeIDs), and returns an HTTP-style status plus a response
|
|
// body OR an error. A returned error is surfaced to the daemon as a non-2xx
|
|
// RPC response so it can fall back to HTTP. The handler runs in its own
|
|
// goroutine, so it must not assume it owns the read pump.
|
|
type RPCHandler func(ctx context.Context, identity ClientIdentity, method string, body json.RawMessage) (status int, respBody json.RawMessage, err error)
|
|
|
|
// maxInFlightRPCPerClient bounds concurrent RPC handlers per connection so a
|
|
// single daemon cannot fan out unbounded goroutines / DB work over one socket.
|
|
const maxInFlightRPCPerClient = 8
|
|
|
|
// MessageKindRecorder is the optional metric hook called once per inbound
|
|
// daemon WebSocket frame. kind is the protocol message type with the
|
|
// "daemon:" prefix stripped (e.g. "heartbeat") or the literal "unknown" for
|
|
// types we don't model. A nil recorder is safely no-op'd.
|
|
type MessageKindRecorder interface {
|
|
RecordDaemonWSMessageReceived(kind string)
|
|
}
|
|
|
|
// Hub keeps daemon WebSocket connections indexed by runtime ID. Messages are
|
|
// best-effort wakeup hints; the daemon still uses HTTP claim for correctness.
|
|
type Hub struct {
|
|
upgrader websocket.Upgrader
|
|
|
|
mu sync.RWMutex
|
|
clients map[*client]bool
|
|
byRuntime map[string]map[*client]bool
|
|
byWorkspace map[string]map[*client]bool
|
|
byUser map[string]map[*client]bool
|
|
|
|
hbMu sync.RWMutex
|
|
onHeartbeat HeartbeatHandler
|
|
|
|
rpcMu sync.RWMutex
|
|
onRPC RPCHandler
|
|
|
|
kindMu sync.RWMutex
|
|
kindRecorder MessageKindRecorder
|
|
}
|
|
|
|
func NewHub() *Hub {
|
|
return &Hub{
|
|
upgrader: websocket.Upgrader{
|
|
// Daemon clients authenticate with Authorization headers before the
|
|
// upgrade. Browsers cannot set those headers through the native WS API,
|
|
// and DaemonAuth does not accept cookies, so cookie-based CSWSH does
|
|
// not apply to this endpoint. Re-evaluate this if DaemonAuth ever
|
|
// grows cookie fallback.
|
|
CheckOrigin: func(r *http.Request) bool { return true },
|
|
},
|
|
clients: make(map[*client]bool),
|
|
byRuntime: make(map[string]map[*client]bool),
|
|
byWorkspace: make(map[string]map[*client]bool),
|
|
byUser: make(map[string]map[*client]bool),
|
|
}
|
|
}
|
|
|
|
// SetHeartbeatHandler installs the callback used for daemon:heartbeat frames.
|
|
// Wiring is done after handler construction because the handler depends on
|
|
// DB queries that aren't available when the hub is built. A nil handler
|
|
// disables WS heartbeat processing — daemons fall back to HTTP heartbeat
|
|
// transparently because their fallback timer fires whenever no ack arrives.
|
|
func (h *Hub) SetHeartbeatHandler(fn HeartbeatHandler) {
|
|
if h == nil {
|
|
return
|
|
}
|
|
h.hbMu.Lock()
|
|
h.onHeartbeat = fn
|
|
h.hbMu.Unlock()
|
|
}
|
|
|
|
func (h *Hub) heartbeatHandler() HeartbeatHandler {
|
|
h.hbMu.RLock()
|
|
defer h.hbMu.RUnlock()
|
|
return h.onHeartbeat
|
|
}
|
|
|
|
// SetRPCHandler installs the callback used for daemon:rpc_request frames
|
|
// (MUL-4257). Like SetHeartbeatHandler it is wired after handler construction.
|
|
// A nil handler disables WS RPC — daemons fall back to the HTTP claim endpoint.
|
|
func (h *Hub) SetRPCHandler(fn RPCHandler) {
|
|
if h == nil {
|
|
return
|
|
}
|
|
h.rpcMu.Lock()
|
|
h.onRPC = fn
|
|
h.rpcMu.Unlock()
|
|
}
|
|
|
|
func (h *Hub) rpcHandler() RPCHandler {
|
|
h.rpcMu.RLock()
|
|
defer h.rpcMu.RUnlock()
|
|
return h.onRPC
|
|
}
|
|
|
|
// SetMessageKindRecorder installs an optional callback fired exactly once per
|
|
// inbound daemon WebSocket frame. Used by the metrics layer to count traffic
|
|
// by handler kind without hard-coupling the hub to any specific collector.
|
|
func (h *Hub) SetMessageKindRecorder(rec MessageKindRecorder) {
|
|
if h == nil {
|
|
return
|
|
}
|
|
h.kindMu.Lock()
|
|
h.kindRecorder = rec
|
|
h.kindMu.Unlock()
|
|
}
|
|
|
|
func (h *Hub) messageKindRecorder() MessageKindRecorder {
|
|
if h == nil {
|
|
return nil
|
|
}
|
|
h.kindMu.RLock()
|
|
defer h.kindMu.RUnlock()
|
|
return h.kindRecorder
|
|
}
|
|
|
|
func (h *Hub) HandleWebSocket(w http.ResponseWriter, r *http.Request, identity ClientIdentity) {
|
|
if len(identity.RuntimeIDs) == 0 && identity.UserID == "" {
|
|
http.Error(w, `{"error":"runtime_ids or user identity required"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
conn, err := h.upgrader.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
slog.Error("daemon websocket upgrade failed", "error", err)
|
|
return
|
|
}
|
|
|
|
runtimes := make(map[string]struct{}, len(identity.RuntimeIDs))
|
|
for _, runtimeID := range identity.RuntimeIDs {
|
|
if runtimeID != "" {
|
|
runtimes[runtimeID] = struct{}{}
|
|
}
|
|
}
|
|
c := &client{
|
|
hub: h,
|
|
conn: conn,
|
|
send: make(chan []byte, 16),
|
|
identity: identity,
|
|
runtimes: runtimes,
|
|
rpcSem: make(chan struct{}, maxInFlightRPCPerClient),
|
|
}
|
|
c.ctx, c.cancel = context.WithCancel(context.Background())
|
|
h.register(c)
|
|
|
|
go c.writePump()
|
|
go c.readPump()
|
|
}
|
|
|
|
// NotifyTaskAvailable sends a best-effort wakeup to daemons watching runtimeID.
|
|
func (h *Hub) NotifyTaskAvailable(runtimeID, taskID string) {
|
|
h.notifyTaskAvailable(runtimeID, taskID, "")
|
|
}
|
|
|
|
// NotifyRuntimeProfilesChanged asks connected daemons in workspaceID to pull
|
|
// runtime profiles after a create, update, disable, or delete.
|
|
func (h *Hub) NotifyRuntimeProfilesChanged(workspaceID, profileID string) {
|
|
h.notifyRuntimeProfilesChanged(workspaceID, profileID, "")
|
|
}
|
|
|
|
// NotifyWorkspacesChanged asks every connected daemon authenticated as userID
|
|
// to reconcile its workspace membership set.
|
|
func (h *Hub) NotifyWorkspacesChanged(userID string) {
|
|
h.notifyWorkspacesChanged(userID, "")
|
|
}
|
|
|
|
func (h *Hub) notifyTaskAvailable(runtimeID, taskID, eventID string) {
|
|
if h == nil || runtimeID == "" {
|
|
return
|
|
}
|
|
data, err := taskAvailableFrame(runtimeID, taskID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
delivered, deduped := h.notifyFrame(runtimeID, data, eventID)
|
|
if delivered {
|
|
M.WakeupDeliveredHit.Add(1)
|
|
} else if !deduped {
|
|
M.WakeupDeliveredMiss.Add(1)
|
|
}
|
|
}
|
|
|
|
func (h *Hub) notifyRuntimeProfilesChanged(workspaceID, profileID, eventID string) {
|
|
if h == nil || workspaceID == "" {
|
|
return
|
|
}
|
|
data, err := runtimeProfilesChangedFrame(workspaceID, profileID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
h.notifyWorkspaceFrame(workspaceID, data, eventID)
|
|
}
|
|
|
|
func (h *Hub) notifyWorkspacesChanged(userID, eventID string) {
|
|
if h == nil || userID == "" {
|
|
return
|
|
}
|
|
data, err := workspacesChangedFrame()
|
|
if err != nil {
|
|
return
|
|
}
|
|
h.notifyUserFrame(userID, data, eventID)
|
|
}
|
|
|
|
func (h *Hub) DeliverDaemonRuntime(scopeID string, frame []byte, eventID string) {
|
|
if h == nil {
|
|
return
|
|
}
|
|
M.WakeupReceivedTotal.Add(1)
|
|
var msg protocol.Message
|
|
if err := json.Unmarshal(frame, &msg); err != nil {
|
|
slog.Debug("daemon websocket relay: invalid frame", "error", err, "scope_id", scopeID, "event_id", eventID)
|
|
M.WakeupDeliveredMiss.Add(1)
|
|
return
|
|
}
|
|
switch msg.Type {
|
|
case protocol.EventDaemonTaskAvailable:
|
|
var payload protocol.TaskAvailablePayload
|
|
if err := json.Unmarshal(msg.Payload, &payload); err != nil || payload.RuntimeID == "" {
|
|
slog.Debug("daemon websocket relay: invalid task_available payload", "error", err, "scope_id", scopeID, "event_id", eventID)
|
|
M.WakeupDeliveredMiss.Add(1)
|
|
return
|
|
}
|
|
delivered, deduped := h.notifyFrame(payload.RuntimeID, frame, eventID)
|
|
if delivered {
|
|
M.WakeupDeliveredHit.Add(1)
|
|
} else if !deduped {
|
|
M.WakeupDeliveredMiss.Add(1)
|
|
}
|
|
case protocol.EventDaemonRuntimeProfilesChanged:
|
|
var payload protocol.RuntimeProfilesChangedPayload
|
|
if err := json.Unmarshal(msg.Payload, &payload); err != nil || payload.WorkspaceID == "" {
|
|
slog.Debug("daemon websocket relay: invalid runtime_profiles_changed payload", "error", err, "scope_id", scopeID, "event_id", eventID)
|
|
M.WakeupDeliveredMiss.Add(1)
|
|
return
|
|
}
|
|
delivered, deduped := h.notifyWorkspaceFrame(payload.WorkspaceID, frame, eventID)
|
|
if delivered {
|
|
M.WakeupDeliveredHit.Add(1)
|
|
} else if !deduped {
|
|
M.WakeupDeliveredMiss.Add(1)
|
|
}
|
|
case protocol.EventDaemonWorkspacesChanged:
|
|
delivered, deduped := h.notifyUserFrame(scopeID, frame, eventID)
|
|
if delivered {
|
|
M.WakeupDeliveredHit.Add(1)
|
|
} else if !deduped {
|
|
M.WakeupDeliveredMiss.Add(1)
|
|
}
|
|
default:
|
|
M.WakeupDeliveredMiss.Add(1)
|
|
return
|
|
}
|
|
}
|
|
|
|
func (h *Hub) notifyFrame(runtimeID string, data []byte, eventID string) (delivered bool, deduped bool) {
|
|
h.mu.RLock()
|
|
clients := h.byRuntime[runtimeID]
|
|
slow := make([]*client, 0)
|
|
for c := range clients {
|
|
if !c.markSeen(eventID) {
|
|
deduped = true
|
|
continue
|
|
}
|
|
select {
|
|
case c.send <- data:
|
|
delivered = true
|
|
default:
|
|
slow = append(slow, c)
|
|
}
|
|
}
|
|
h.mu.RUnlock()
|
|
|
|
for _, c := range slow {
|
|
h.unregister(c)
|
|
c.conn.Close()
|
|
}
|
|
if len(slow) > 0 {
|
|
M.SlowEvictionsTotal.Add(int64(len(slow)))
|
|
}
|
|
return delivered, deduped
|
|
}
|
|
|
|
func (h *Hub) notifyWorkspaceFrame(workspaceID string, data []byte, eventID string) (delivered bool, deduped bool) {
|
|
h.mu.RLock()
|
|
clients := h.byWorkspace[workspaceID]
|
|
slow := make([]*client, 0)
|
|
for c := range clients {
|
|
if !c.markSeen(eventID) {
|
|
deduped = true
|
|
continue
|
|
}
|
|
select {
|
|
case c.send <- data:
|
|
delivered = true
|
|
default:
|
|
slow = append(slow, c)
|
|
}
|
|
}
|
|
h.mu.RUnlock()
|
|
|
|
for _, c := range slow {
|
|
h.unregister(c)
|
|
c.conn.Close()
|
|
}
|
|
if len(slow) > 0 {
|
|
M.SlowEvictionsTotal.Add(int64(len(slow)))
|
|
}
|
|
return delivered, deduped
|
|
}
|
|
|
|
func (h *Hub) notifyUserFrame(userID string, data []byte, eventID string) (delivered bool, deduped bool) {
|
|
h.mu.RLock()
|
|
clients := h.byUser[userID]
|
|
slow := make([]*client, 0)
|
|
for c := range clients {
|
|
if !c.markSeen(eventID) {
|
|
deduped = true
|
|
continue
|
|
}
|
|
select {
|
|
case c.send <- data:
|
|
delivered = true
|
|
default:
|
|
slow = append(slow, c)
|
|
}
|
|
}
|
|
h.mu.RUnlock()
|
|
|
|
for _, c := range slow {
|
|
h.unregister(c)
|
|
c.conn.Close()
|
|
}
|
|
if len(slow) > 0 {
|
|
M.SlowEvictionsTotal.Add(int64(len(slow)))
|
|
}
|
|
return delivered, deduped
|
|
}
|
|
|
|
func taskAvailableFrame(runtimeID, taskID string) ([]byte, error) {
|
|
return json.Marshal(protocol.Message{
|
|
Type: protocol.EventDaemonTaskAvailable,
|
|
Payload: mustMarshalRaw(protocol.TaskAvailablePayload{
|
|
RuntimeID: runtimeID,
|
|
TaskID: taskID,
|
|
}),
|
|
})
|
|
}
|
|
|
|
func runtimeProfilesChangedFrame(workspaceID, profileID string) ([]byte, error) {
|
|
return json.Marshal(protocol.Message{
|
|
Type: protocol.EventDaemonRuntimeProfilesChanged,
|
|
Payload: mustMarshalRaw(protocol.RuntimeProfilesChangedPayload{
|
|
WorkspaceID: workspaceID,
|
|
RuntimeProfileID: profileID,
|
|
}),
|
|
})
|
|
}
|
|
|
|
func workspacesChangedFrame() ([]byte, error) {
|
|
return json.Marshal(protocol.Message{
|
|
Type: protocol.EventDaemonWorkspacesChanged,
|
|
Payload: mustMarshalRaw(protocol.WorkspacesChangedPayload{}),
|
|
})
|
|
}
|
|
|
|
func mustMarshalRaw(v any) json.RawMessage {
|
|
data, err := json.Marshal(v)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return data
|
|
}
|
|
|
|
func (h *Hub) RuntimeConnectionCount(runtimeID string) int {
|
|
h.mu.RLock()
|
|
defer h.mu.RUnlock()
|
|
return len(h.byRuntime[runtimeID])
|
|
}
|
|
|
|
func (h *Hub) WorkspaceConnectionCount(workspaceID string) int {
|
|
h.mu.RLock()
|
|
defer h.mu.RUnlock()
|
|
return len(h.byWorkspace[workspaceID])
|
|
}
|
|
|
|
func (h *Hub) UserConnectionCount(userID string) int {
|
|
h.mu.RLock()
|
|
defer h.mu.RUnlock()
|
|
return len(h.byUser[userID])
|
|
}
|
|
|
|
func (h *Hub) register(c *client) {
|
|
h.mu.Lock()
|
|
h.clients[c] = true
|
|
for runtimeID := range c.runtimes {
|
|
conns := h.byRuntime[runtimeID]
|
|
if conns == nil {
|
|
conns = make(map[*client]bool)
|
|
h.byRuntime[runtimeID] = conns
|
|
}
|
|
conns[c] = true
|
|
}
|
|
workspaceIDs := c.identity.AuthorizedWorkspaceIDs()
|
|
for _, workspaceID := range workspaceIDs {
|
|
conns := h.byWorkspace[workspaceID]
|
|
if conns == nil {
|
|
conns = make(map[*client]bool)
|
|
h.byWorkspace[workspaceID] = conns
|
|
}
|
|
conns[c] = true
|
|
}
|
|
if userID := c.identity.UserID; userID != "" {
|
|
conns := h.byUser[userID]
|
|
if conns == nil {
|
|
conns = make(map[*client]bool)
|
|
h.byUser[userID] = conns
|
|
}
|
|
conns[c] = true
|
|
}
|
|
total := len(h.clients)
|
|
h.mu.Unlock()
|
|
|
|
M.ConnectsTotal.Add(1)
|
|
M.ActiveConnections.Add(1)
|
|
slog.Info("daemon websocket connected",
|
|
"daemon_id", c.identity.DaemonID,
|
|
"user_id", c.identity.UserID,
|
|
"workspace_id", c.identity.PrimaryWorkspaceID(),
|
|
"workspace_ids", workspaceIDs,
|
|
"runtimes", len(c.runtimes),
|
|
"client_version", c.identity.ClientVersion,
|
|
"total_clients", total,
|
|
)
|
|
}
|
|
|
|
func (h *Hub) unregister(c *client) {
|
|
h.mu.Lock()
|
|
if !h.clients[c] {
|
|
h.mu.Unlock()
|
|
return
|
|
}
|
|
delete(h.clients, c)
|
|
for runtimeID := range c.runtimes {
|
|
if conns := h.byRuntime[runtimeID]; conns != nil {
|
|
delete(conns, c)
|
|
if len(conns) == 0 {
|
|
delete(h.byRuntime, runtimeID)
|
|
}
|
|
}
|
|
}
|
|
workspaceIDs := c.identity.AuthorizedWorkspaceIDs()
|
|
for _, workspaceID := range workspaceIDs {
|
|
if conns := h.byWorkspace[workspaceID]; conns != nil {
|
|
delete(conns, c)
|
|
if len(conns) == 0 {
|
|
delete(h.byWorkspace, workspaceID)
|
|
}
|
|
}
|
|
}
|
|
if userID := c.identity.UserID; userID != "" {
|
|
if conns := h.byUser[userID]; conns != nil {
|
|
delete(conns, c)
|
|
if len(conns) == 0 {
|
|
delete(h.byUser, userID)
|
|
}
|
|
}
|
|
}
|
|
c.sendMu.Lock()
|
|
c.sendClosed = true
|
|
close(c.send)
|
|
c.sendMu.Unlock()
|
|
total := len(h.clients)
|
|
h.mu.Unlock()
|
|
|
|
M.DisconnectsTotal.Add(1)
|
|
M.ActiveConnections.Add(-1)
|
|
slog.Info("daemon websocket disconnected",
|
|
"daemon_id", c.identity.DaemonID,
|
|
"user_id", c.identity.UserID,
|
|
"workspace_id", c.identity.PrimaryWorkspaceID(),
|
|
"workspace_ids", workspaceIDs,
|
|
"runtimes", len(c.runtimes),
|
|
"total_clients", total,
|
|
)
|
|
}
|
|
|
|
func (c *client) readPump() {
|
|
defer func() {
|
|
// Cancel first so async RPC handlers stop before we close the send
|
|
// channel, then unregister (which marks send closed).
|
|
c.cancel()
|
|
c.hub.unregister(c)
|
|
c.conn.Close()
|
|
}()
|
|
|
|
// Read limit sized for daemon:rpc_request frames carrying a machine's full
|
|
// runtime_id set (MUL-4257), well above the tiny heartbeat/wakeup frames.
|
|
c.conn.SetReadLimit(64 * 1024)
|
|
c.conn.SetReadDeadline(time.Now().Add(pongWait))
|
|
c.conn.SetPongHandler(func(string) error {
|
|
c.conn.SetReadDeadline(time.Now().Add(pongWait))
|
|
return nil
|
|
})
|
|
|
|
for {
|
|
_, raw, err := c.conn.ReadMessage()
|
|
if err != nil {
|
|
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
|
|
slog.Debug("daemon websocket read error", "error", err, "daemon_id", c.identity.DaemonID)
|
|
}
|
|
return
|
|
}
|
|
c.handleFrame(raw)
|
|
}
|
|
}
|
|
|
|
func (c *client) handleFrame(raw []byte) {
|
|
var msg protocol.Message
|
|
if err := json.Unmarshal(raw, &msg); err != nil {
|
|
slog.Debug("daemon websocket invalid frame", "error", err, "daemon_id", c.identity.DaemonID)
|
|
if rec := c.hub.messageKindRecorder(); rec != nil {
|
|
rec.RecordDaemonWSMessageReceived("invalid")
|
|
}
|
|
return
|
|
}
|
|
kind := strings.TrimPrefix(msg.Type, "daemon:")
|
|
if kind == "" {
|
|
kind = "unknown"
|
|
}
|
|
if rec := c.hub.messageKindRecorder(); rec != nil {
|
|
rec.RecordDaemonWSMessageReceived(kind)
|
|
}
|
|
switch msg.Type {
|
|
case protocol.EventDaemonHeartbeat:
|
|
c.handleHeartbeatFrame(msg.Payload)
|
|
case protocol.EventDaemonRPCRequest:
|
|
c.handleRPCFrame(msg.Payload)
|
|
default:
|
|
// Unknown app messages are intentionally ignored for forward
|
|
// compatibility with future daemon → server message types.
|
|
}
|
|
}
|
|
|
|
// handleRPCFrame processes a generic daemon:rpc_request (MUL-4257): it runs the
|
|
// registered RPC handler in its own goroutine (so a DB-bound claim does not
|
|
// stall the read pump or the next heartbeat) and writes back a
|
|
// daemon:rpc_response echoing the request id. A missing handler or a full
|
|
// in-flight slot yields a non-2xx response so the daemon falls back to HTTP.
|
|
func (c *client) handleRPCFrame(raw json.RawMessage) {
|
|
var req protocol.RPCRequestPayload
|
|
if err := json.Unmarshal(raw, &req); err != nil {
|
|
slog.Debug("daemon websocket rpc invalid payload", "error", err, "daemon_id", c.identity.DaemonID)
|
|
return
|
|
}
|
|
if req.RequestID == "" {
|
|
slog.Debug("daemon websocket rpc missing request_id", "daemon_id", c.identity.DaemonID)
|
|
return
|
|
}
|
|
handler := c.hub.rpcHandler()
|
|
if handler == nil {
|
|
c.sendRPCResponse(req.RequestID, http.StatusServiceUnavailable, nil, "rpc handler unavailable")
|
|
return
|
|
}
|
|
// Bound concurrent handlers; if saturated, tell the daemon to fall back
|
|
// rather than queueing unbounded work on one socket.
|
|
select {
|
|
case c.rpcSem <- struct{}{}:
|
|
default:
|
|
c.sendRPCResponse(req.RequestID, http.StatusTooManyRequests, nil, "too many in-flight rpc requests")
|
|
return
|
|
}
|
|
go func() {
|
|
defer func() { <-c.rpcSem }()
|
|
// Bound server-side execution by the caller's requested budget (in
|
|
// addition to the connection ctx), so a slow RPC is cancelled — and its
|
|
// work rolled back — rather than committing after the daemon has already
|
|
// timed out and fallen back to HTTP (MUL-4257). The daemon waits a grace
|
|
// period beyond this budget, so a claim that DID commit before the
|
|
// deadline still reports back in time.
|
|
hctx := c.ctx
|
|
if req.TimeoutMs > 0 {
|
|
var cancel context.CancelFunc
|
|
hctx, cancel = context.WithTimeout(c.ctx, time.Duration(req.TimeoutMs)*time.Millisecond)
|
|
defer cancel()
|
|
}
|
|
status, body, err := handler(hctx, c.identity, req.Method, req.Body)
|
|
if err != nil {
|
|
if status < 400 {
|
|
status = http.StatusInternalServerError
|
|
}
|
|
c.sendRPCResponse(req.RequestID, status, nil, err.Error())
|
|
return
|
|
}
|
|
c.sendRPCResponse(req.RequestID, status, body, "")
|
|
}()
|
|
}
|
|
|
|
func (c *client) sendRPCResponse(requestID string, status int, body json.RawMessage, errMsg string) {
|
|
frame, err := json.Marshal(protocol.Message{
|
|
Type: protocol.EventDaemonRPCResponse,
|
|
Payload: mustMarshalRaw(protocol.RPCResponsePayload{
|
|
RequestID: requestID,
|
|
Status: status,
|
|
Body: body,
|
|
Error: errMsg,
|
|
}),
|
|
})
|
|
if err != nil {
|
|
slog.Debug("daemon websocket rpc response marshal failed", "error", err)
|
|
return
|
|
}
|
|
if !c.trySend(frame) {
|
|
// Send buffer full or connection closing — drop the response; the
|
|
// daemon's per-request timeout fires and it falls back to HTTP.
|
|
slog.Debug("daemon websocket rpc response dropped",
|
|
"daemon_id", c.identity.DaemonID, "request_id", requestID)
|
|
}
|
|
}
|
|
|
|
// handleHeartbeatFrame processes an inbound daemon:heartbeat from the daemon,
|
|
// invokes the hub's handler, and writes back a daemon:heartbeat_ack.
|
|
func (c *client) handleHeartbeatFrame(raw json.RawMessage) {
|
|
handler := c.hub.heartbeatHandler()
|
|
if handler == nil {
|
|
// Server doesn't have a heartbeat handler wired — daemon will time
|
|
// out waiting for an ack and fall back to HTTP heartbeat.
|
|
return
|
|
}
|
|
|
|
var payload protocol.DaemonHeartbeatRequestPayload
|
|
if err := json.Unmarshal(raw, &payload); err != nil {
|
|
slog.Debug("daemon websocket heartbeat invalid payload", "error", err, "daemon_id", c.identity.DaemonID)
|
|
return
|
|
}
|
|
if payload.RuntimeID == "" {
|
|
slog.Debug("daemon websocket heartbeat missing runtime_id", "daemon_id", c.identity.DaemonID)
|
|
return
|
|
}
|
|
if _, ok := c.runtimes[payload.RuntimeID]; !ok {
|
|
// The connection authenticated for a fixed runtime set; reject any
|
|
// heartbeat for a runtime the client did not register for.
|
|
slog.Warn("daemon websocket heartbeat for unauthorized runtime",
|
|
"daemon_id", c.identity.DaemonID,
|
|
"runtime_id", payload.RuntimeID)
|
|
return
|
|
}
|
|
|
|
// Intentionally do NOT wrap this ctx with WithTimeout. The handler
|
|
// reaches LocalSkill{List,Import}Store.PopPending, whose Redis Lua
|
|
// claim script has side effects (ZREM + SET-running) that cannot be
|
|
// safely un-run if the client cancels mid-script — the same invariant
|
|
// that keeps the HTTP heartbeat from putting a per-call timeout on
|
|
// PopPending. The natural bound is the read pump's lifetime (the conn
|
|
// closes if the daemon goes away) plus Redis's own server-side limits.
|
|
ack, err := handler(context.Background(), c.identity, payload.RuntimeID, payload.SupportsBatchImport)
|
|
if err != nil {
|
|
slog.Warn("daemon websocket heartbeat handler failed",
|
|
"error", err,
|
|
"daemon_id", c.identity.DaemonID,
|
|
"runtime_id", payload.RuntimeID)
|
|
return
|
|
}
|
|
if ack == nil {
|
|
return
|
|
}
|
|
frame, err := json.Marshal(protocol.Message{
|
|
Type: protocol.EventDaemonHeartbeatAck,
|
|
Payload: mustMarshalRaw(ack),
|
|
})
|
|
if err != nil {
|
|
slog.Debug("daemon websocket heartbeat ack marshal failed", "error", err)
|
|
return
|
|
}
|
|
if !c.trySend(frame) {
|
|
// Send buffer full or connection closing — drop; HTTP heartbeat resumes.
|
|
slog.Debug("daemon websocket heartbeat ack dropped",
|
|
"daemon_id", c.identity.DaemonID,
|
|
"runtime_id", payload.RuntimeID)
|
|
}
|
|
}
|
|
|
|
func (c *client) writePump() {
|
|
ticker := time.NewTicker(pingPeriod)
|
|
defer func() {
|
|
ticker.Stop()
|
|
c.conn.Close()
|
|
}()
|
|
|
|
for {
|
|
select {
|
|
case message, ok := <-c.send:
|
|
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
|
|
if !ok {
|
|
c.conn.WriteMessage(websocket.CloseMessage, []byte{})
|
|
return
|
|
}
|
|
if err := c.conn.WriteMessage(websocket.TextMessage, message); err != nil {
|
|
slog.Debug("daemon websocket write error", "error", err, "daemon_id", c.identity.DaemonID)
|
|
return
|
|
}
|
|
case <-ticker.C:
|
|
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
|
|
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|