diff --git a/api/openapi.yaml b/api/openapi.yaml index e96d165..56fbba1 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1787,17 +1787,18 @@ components: sse_clients: { type: integer } clients: type: array - description: Each distinct user, Mac and app build with an open event stream, oldest connection first. + description: Each running app that holds its /api/clients/connect WebSocket, oldest connection first. items: { $ref: "#/components/schemas/ConnectedClient" } ConnectedClient: type: object - required: [login, host, commit, connected_at] + required: [id, login, host, commit, connected_at] properties: + id: { type: string, description: The ID the app made up when it started, from its Byre-Client header. } login: { type: string } - host: { type: string, description: The Mac's name from its Byre-Host header; empty when the client sent none. } + host: { type: string, description: The Mac's name from its Byre-Host header. } commit: { type: string, description: The git commit the app was built from, from its Byre-Commit header; empty when the client sent none. } - connected_at: { type: string, format: date-time, description: When the client's oldest open stream connected. } + connected_at: { type: string, format: date-time, description: When the client's WebSocket connected. } last_action_at: { type: string, format: date-time, description: When byre last received a request the human made from this client since byre started; absent when none. } ClientRequest: diff --git a/docs/operations.md b/docs/operations.md index 8dbfb30..2c96b30 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -212,7 +212,7 @@ Codex runs with its own home, `codex` under the data directory (`CODEX_HOME`, se ## Status -`GET /api/admin/status` reports `running_turns` (task sessions now `running`, as session, task and vendor; lead turns aren't listed); `rate_limits`, the latest status each vendor sent since byre started (Claude's `rate_limit_info`, with `windowMins` added to each `unifiedWindows` entry by `addClaudeWindowMins` in `internal/agents/manager.go` from the window's name, since Claude doesn't send a length; Codex's `account/rateLimits/updated` reshaped by `codexRateLimit` in `internal/agents/codex.go` into the same `status`, `rateLimitType` and `unifiedWindows` fields, `windowMins` from its `windowDurationMins`). Claude only reports during its turns; Codex is also asked once at startup (`readCodexRateLimit`, a short-lived `codex app-server` that calls `account/rateLimits/read` without opening a thread), so its entry is there before any Codex turn runs, and every Codex update broadcasts `admin.health`; `agent_processes`, `zombies`, `stuck_after_seconds` and `protocol_errors` (below); `clients`, one entry per distinct login, `host` and `commit` among the open `GET /api/events` streams, with the oldest stream's `connected_at` (the app opens two streams, so it shows once). `host` and `commit` come from the `Byre-Host` and `Byre-Commit` headers, which the macOS app sends on every request: the Mac's name (`Host.current().localizedName`, else `ProcessInfo.processInfo.hostName`, the name agents' client tools take, see "Client Macs") and the `ByreCommit` that `mac/bundle.sh` stamped into its `Info.plist`; they are empty for a client that sends neither. To check that a rebuilt app is running, compare each client's `commit` with the commit it was built from. `last_action_at` is when byre last received a request the human made from that login, host and commit: any request other than a `GET`, except the two the app sends on its own (`PUT /api/me/pane-focus` and `PUT /api/tasks/{taskId}/summary-read`, listed in `automaticRequests` in `internal/server/server.go`), and every binary message on a terminal WebSocket, which includes the replies SwiftTerm sends by itself to queries in the output (cursor position and the like). The `identify` middleware and `terminalInput` record it in the hub's memory, so it is absent until that client's first action after byre starts. An action at least a second after the last one from that client that broadcast, and every `GET /api/events` stream opening or closing, broadcasts an unstored `admin.clients` event (`clientsChanged`). And server counts (start time, agent processes, projects, active tasks, SSE clients, database size including WAL, last event seq). +`GET /api/admin/status` reports `running_turns` (task sessions now `running`, as session, task and vendor; lead turns aren't listed); `rate_limits`, the latest status each vendor sent since byre started (Claude's `rate_limit_info`, with `windowMins` added to each `unifiedWindows` entry by `addClaudeWindowMins` in `internal/agents/manager.go` from the window's name, since Claude doesn't send a length; Codex's `account/rateLimits/updated` reshaped by `codexRateLimit` in `internal/agents/codex.go` into the same `status`, `rateLimitType` and `unifiedWindows` fields, `windowMins` from its `windowDurationMins`). Claude only reports during its turns; Codex is also asked once at startup (`readCodexRateLimit`, a short-lived `codex app-server` that calls `account/rateLimits/read` without opening a thread), so its entry is there before any Codex turn runs, and every Codex update broadcasts `admin.health`; `agent_processes`, `zombies`, `stuck_after_seconds` and `protocol_errors` (below); `clients`, one entry per command channel byre holds (the `/api/clients/connect` WebSockets in `internal/clients`, see "Client Macs"), oldest first, so it lists exactly the Macs `list_clients` and the `client_*` tools can reach; a Mac whose event streams are open but whose command channel is down isn't listed. `id`, `host` and `commit` come from the `Byre-Client`, `Byre-Host` and `Byre-Commit` headers, which the macOS app sends on every request: a UUID the app makes up each time it starts, the Mac's name (`Host.current().localizedName`, else `ProcessInfo.processInfo.hostName`, the name agents' client tools take) and the `ByreCommit` that `mac/bundle.sh` stamped into its `Info.plist`. To check that a rebuilt app is running, compare each client's `commit` with the commit it was built from. `last_action_at` is when byre last received a request the human made from that client `id`: any request other than a `GET`, except the two the app sends on its own (`PUT /api/me/pane-focus` and `PUT /api/tasks/{taskId}/summary-read`, listed in `automaticRequests` in `internal/server/server.go`), and every binary message on a terminal WebSocket, which includes the replies SwiftTerm sends by itself to queries in the output (cursor position and the like). The `identify` middleware and `terminalInput` record it in the client registry's memory (`Registry.Acted` in `internal/clients`), so it is absent until that client's first action after byre starts. The registry keeps each client by `id` across command-channel drops, so a Mac that sleeps and reconnects keeps its `last_action_at`, and forgets a client 24 hours after it was last connected or acted, since an app that quit never comes back under that `id`. An action at least a second after the last one from that client that broadcast, every `GET /api/events` stream opening or closing, and every command channel connecting or disconnecting broadcasts an unstored `admin.clients` event (`clientsChanged`). And server counts (start time, agent processes, projects, active tasks, SSE clients, database size including WAL, last event seq). Agent health is `internal/agents/health.go`: @@ -315,11 +315,11 @@ Answers reach the agent as one message, listing each answer under the question o ## Client Macs -Task agents use the Macs the human has byre's app open on directly, with no approval, as they use the machine byre runs on. While it runs, the app (`mac/Sources/Byre/Remote.swift`, started with the global event stream and stopped with it) keeps a WebSocket open to `GET /api/clients/connect`, reconnecting 2s after it drops. The client is named by its `Byre-Host` header (required), and a newer connection from the same host replaces the older one. byre keeps the connections in memory (`internal/clients`), pinging each every 30s and dropping one that doesn't answer within 15s. Over it byre sends `ClientRequest` text messages, each with a fresh `id`, and the app answers each with a `ClientReply` carrying that `id` (`error` when it couldn't carry it out), in any order, handling requests concurrently. A request whose client disconnects before replying fails with " disconnected before replying". +Task agents use the Macs the human has byre's app open on directly, with no approval, as they use the machine byre runs on. While it runs, the app (`mac/Sources/Byre/Remote.swift`, started with the global event stream and stopped with it) keeps a WebSocket open to `GET /api/clients/connect`, reconnecting 2s after it drops. The connection is keyed by its `Byre-Client` header and named by its `Byre-Host` header (both required); a newer connection with the same `Byre-Client` replaces the older one, while two apps open on one Mac each keep their own. The client tools take the host name, or the `id` from `list_clients`, which they need when more than one app is connected from that host (the call fails listing the ids). byre keeps the connections in memory (`internal/clients`), pinging each every 30s and dropping one that doesn't answer within 15s. The app pings too, every 10s, and reconnects when it has had no pong for 25s. That catches a socket byre dropped while the Mac slept: the close never reached the Mac, and without something sent on it the app would wait on it indefinitely. Over it byre sends `ClientRequest` text messages, each with a fresh `id`, and the app answers each with a `ClientReply` carrying that `id` (`error` when it couldn't carry it out), in any order, handling requests concurrently. A request whose client disconnects before replying fails with " disconnected before replying". Implementer sessions get eight MCP tools for it (`internal/agents/clients.go`). The app sets its WebSocket's `maximumMessageSize` to 256 MiB, the same as byre's read limit, so file contents fit in one message each way. -- `list_clients`: the connected clients, with each one's login, connection time and app commit, and its `last_action_at` from Status (see "Status") matched on login, host and commit; the most recently active is listed first and marked. +- `list_clients`: the status's `clients` (see "Status"), with each one's host, login, `id`, connection time, app commit and `last_action_at`; the most recently active is listed first and marked. - `client_bash`: `op: bash`. The app runs `/bin/zsh -l -c ` in the human's home directory through `Process`, which starts it in its own process group, stdin from `/dev/null` and stdout and stderr into one temporary file, and kills the group with `SIGKILL` after `timeout_ms` (default 120000, at most 600000), replying `exit_code` -1. Otherwise `exit_code` is the exit status, or 128 plus the signal. `output` is the file's last 64 KiB. The agent's call waits the timeout plus 30s for the reply. Background children the command leaves are not waited for or killed, and a command that quits the app keeps running. - `client_upload`: `op: upload`. `path` is checked against the worktree through an `os.Root`; byre registers a transfer naming the path's directory and base name under a random id for as long as the call waits (at most 10 minutes), and the app downloads `GET /api/clients/transfers/{transferId}`, which streams `tar -czf - -C -- ` (404 once the call has ended). The app unpacks it into a new `.byre-upload-` directory beside `dest` (`~/` expanded, parent directories created), removes whatever is at `dest`, moves the archive's one entry there and removes the temporary directory. - `client_download`: `op: download`, the reverse. byre makes `dest`'s parent directories through an `os.Root` on the worktree and registers a receiving transfer (`Transfer.Receive`) for a fresh temporary directory; the app runs `tar -czf -C -- ` and `PUT`s it to `/api/clients/transfers/{transferId}`, which writes it once (`O_EXCL`) and answers 204, and 404 for a sending transfer, one already received or one whose call has ended (`GET` likewise answers 404 for a receiving one). byre unpacks it into a new `.byre-download-` directory beside `dest`, removes whatever is at `dest`, renames the archive's one entry there and removes the temporary directories, all inside the `os.Root`. @@ -332,7 +332,7 @@ Implementer sessions get eight MCP tools for it (`internal/agents/clients.go`). A byre restart drops every connection; each app reconnects 2s later, and calls in flight at the restart fail as disconnected. Updating a client's app goes through the tools themselves: `client_upload` the new `byre.app`, then `client_bash` a command that quits the old app and opens the new one, which outlives the app and reconnects. An app that doesn't know the `upload` op can't be updated this way, so its update is by hand. -`log show --last 10m --predicate 'subsystem == "run.fc.byre.mac"'` on a client shows `client request : ` lines, connection drops and `byre-hands` starts and exits; byre's log shows `client connected` and `client disconnected`. +`log show --info --last 10m --predicate 'subsystem == "run.fc.byre.mac"'` on a client shows `client request : ` lines and `byre-hands` starts and exits, which are info level and kept only in memory, so they cover the last few minutes at most; connection drops (`client connection closed`, `client connection got no pong`) are notices, which macOS keeps on disk, so `log show --start` reaches much further back for them; byre's log shows `client connected` and `client disconnected`. ## Changes diff --git a/internal/agents/clients.go b/internal/agents/clients.go index 9fa1ce3..ca87feb 100644 --- a/internal/agents/clients.go +++ b/internal/agents/clients.go @@ -35,7 +35,7 @@ func (m *Manager) clientTools(l *live) []mcp.Tool { { Name: "list_clients", Description: "List the Macs with byre's app open: the human's own machines, not the one byre (and your worktree) runs on. " + - "Each has its name, which the other client tools take as client, the human using it, and when the human last did something in byre from it. " + + "Each has its name, which the other client tools take as client, the id of that running app (pass it as client instead when two apps are open on one Mac), the human using it, and when the human last did something in byre from it. " + "The most recently active one is listed first; it is usually the Mac the human sits at.", InputSchema: json.RawMessage(`{"type": "object", "properties": {}}`), Handler: func(ctx context.Context, raw json.RawMessage) (string, error) { @@ -50,7 +50,7 @@ func (m *Manager) clientTools(l *live) []mcp.Tool { InputSchema: json.RawMessage(`{ "type": "object", "properties": { - "client": {"type": "string", "description": "The client's name from list_clients."}, + "client": {"type": "string", "description": "The client's name from list_clients, or its id when several are connected from that Mac."}, "command": {"type": "string"}, "timeout_ms": {"type": "integer", "description": "Kill the command after this long (default 120000, at most 600000)."} }, @@ -67,7 +67,7 @@ func (m *Manager) clientTools(l *live) []mcp.Tool { InputSchema: json.RawMessage(`{ "type": "object", "properties": { - "client": {"type": "string", "description": "The client's name from list_clients."}, + "client": {"type": "string", "description": "The client's name from list_clients, or its id when several are connected from that Mac."}, "path": {"type": "string", "description": "The file or directory, relative to your worktree."}, "dest": {"type": "string", "description": "Its new path on the client, absolute or starting with ~/, e.g. ~/Applications/byre.app."} }, @@ -84,7 +84,7 @@ func (m *Manager) clientTools(l *live) []mcp.Tool { InputSchema: json.RawMessage(`{ "type": "object", "properties": { - "client": {"type": "string", "description": "The client's name from list_clients."}, + "client": {"type": "string", "description": "The client's name from list_clients, or its id when several are connected from that Mac."}, "path": {"type": "string", "description": "The file or directory on the client, absolute or starting with ~/."}, "dest": {"type": "string", "description": "Its new path, relative to your worktree; parent directories are created."} }, @@ -101,7 +101,7 @@ func (m *Manager) clientTools(l *live) []mcp.Tool { InputSchema: json.RawMessage(`{ "type": "object", "properties": { - "client": {"type": "string", "description": "The client's name from list_clients."}, + "client": {"type": "string", "description": "The client's name from list_clients, or its id when several are connected from that Mac."}, "path": {"type": "string", "description": "The file, absolute or starting with ~/."}, "offset": {"type": "integer", "description": "The line number to start from (default 1)."}, "limit": {"type": "integer", "description": "The most lines to return (default 2000)."} @@ -118,7 +118,7 @@ func (m *Manager) clientTools(l *live) []mcp.Tool { InputSchema: json.RawMessage(`{ "type": "object", "properties": { - "client": {"type": "string", "description": "The client's name from list_clients."}, + "client": {"type": "string", "description": "The client's name from list_clients, or its id when several are connected from that Mac."}, "path": {"type": "string", "description": "The file, absolute or starting with ~/."}, "content": {"type": "string"} }, @@ -135,7 +135,7 @@ func (m *Manager) clientTools(l *live) []mcp.Tool { InputSchema: json.RawMessage(`{ "type": "object", "properties": { - "client": {"type": "string", "description": "The client's name from list_clients."}, + "client": {"type": "string", "description": "The client's name from list_clients, or its id when several are connected from that Mac."}, "path": {"type": "string", "description": "The file, absolute or starting with ~/."}, "old_string": {"type": "string"}, "new_string": {"type": "string"} @@ -156,7 +156,7 @@ func (m *Manager) clientTools(l *live) []mcp.Tool { InputSchema: json.RawMessage(`{ "type": "object", "properties": { - "client": {"type": "string", "description": "The client's name from list_clients."}, + "client": {"type": "string", "description": "The client's name from list_clients, or its id when several are connected from that Mac."}, "tool": {"type": "string", "enum": ["screen_list", "capture", "ax_tree", "act", "browser", "chrome"]}, "arguments": {"type": "object", "description": "The tool's arguments."} }, @@ -174,39 +174,26 @@ func (m *Manager) listClients(ctx context.Context) (string, error) { if err != nil { return "", err } - type row struct { - c clients.Client - last *time.Time - } - rows := []row{} - for _, c := range m.clients.List() { - r := row{c: c} - for _, s := range status.Clients { - if s.Login == c.Login && s.Host == c.Host && s.Commit == c.Commit { - r.last = s.LastActionAt - } - } - rows = append(rows, r) - } + rows := status.Clients if len(rows) == 0 { return "No client Macs are connected.", nil } sort.SliceStable(rows, func(i, j int) bool { - a, b := rows[i].last, rows[j].last + a, b := rows[i].LastActionAt, rows[j].LastActionAt return a != nil && (b == nil || a.After(*b)) }) now := time.Now() b := &strings.Builder{} for i, r := range rows { - fmt.Fprintf(b, "- %s (%s): ", r.c.Host, r.c.Login) - if r.last == nil { + fmt.Fprintf(b, "- %s (%s, id %s): ", r.Host, r.Login, r.Id) + if r.LastActionAt == nil { b.WriteString("no action since byre started") } else { - fmt.Fprintf(b, "last action %s ago", now.Sub(*r.last).Round(time.Second)) + fmt.Fprintf(b, "last action %s ago", now.Sub(*r.LastActionAt).Round(time.Second)) } - fmt.Fprintf(b, ", connected %s ago, app built from %s", now.Sub(r.c.ConnectedAt).Round(time.Second), r.c.Commit) - if i == 0 && r.last != nil { + fmt.Fprintf(b, ", connected %s ago, app built from %s", now.Sub(r.ConnectedAt).Round(time.Second), r.Commit) + if i == 0 && r.LastActionAt != nil { b.WriteString(" (most recently active)") } b.WriteString("\n") diff --git a/internal/api/api.gen.go b/internal/api/api.gen.go index 8760ee9..d5fd35e 100644 --- a/internal/api/api.gen.go +++ b/internal/api/api.gen.go @@ -736,12 +736,15 @@ type ConnectedClient struct { // Commit The git commit the app was built from Commit string `json:"commit"` - // ConnectedAt When the client's oldest open stream connected. + // ConnectedAt When the client's WebSocket connected. ConnectedAt time.Time `json:"connected_at"` - // Host The Mac's name from its Byre-Host header; empty when the client sent none. + // Host The Mac's name from its Byre-Host header. Host string `json:"host"` + // Id The ID the app made up when it started + Id string `json:"id"` + // LastActionAt When byre last received a request the human made from this client since byre started; absent when none. LastActionAt *time.Time `json:"last_action_at,omitempty"` Login string `json:"login"` @@ -1164,7 +1167,7 @@ type Status struct { // AgentProcesses Every agent process byre is tracking, idle sessions included. AgentProcesses []AgentProcess `json:"agent_processes"` - // Clients Each distinct user, Mac and app build with an open event stream, oldest connection first. + // Clients Each running app that holds its /api/clients/connect WebSocket, oldest connection first. Clients []ConnectedClient `json:"clients"` DbBytes int64 `json:"db_bytes"` LastEventSeq int64 `json:"last_event_seq"` @@ -3800,253 +3803,253 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H // const string: with thousands of chunks the chained `+` fold is several // times slower for the Go compiler than parsing a slice literal. var swaggerSpec = []string{ - "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", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/internal/clients/clients.go b/internal/clients/clients.go index c8a2df9..6797713 100644 --- a/internal/clients/clients.go +++ b/internal/clients/clients.go @@ -22,13 +22,16 @@ const ( readLimit = 256 << 20 pingInterval = 30 * time.Second pingTimeout = 15 * time.Second + expireAfter = 24 * time.Hour ) type Client struct { - Login string - Host string - Commit string - ConnectedAt time.Time + ID string + Login string + Host string + Commit string + ConnectedAt time.Time + LastActionAt *time.Time } type Transfer struct { @@ -38,44 +41,91 @@ type Transfer struct { } type conn struct { - Client ws *websocket.Conn done chan struct{} mu sync.Mutex pending map[string]chan api.ClientReply } +type entry struct { + Client + conn *conn + seen time.Time + notified time.Time +} + type Registry struct { mu sync.Mutex - conns map[string]*conn + clients map[string]*entry transfers map[string]Transfer + changed func() } -func New() *Registry { - return &Registry{conns: map[string]*conn{}, transfers: map[string]Transfer{}} +func New(changed func()) *Registry { + return &Registry{clients: map[string]*entry{}, transfers: map[string]Transfer{}, changed: changed} } -func (r *Registry) Serve(ctx context.Context, ws *websocket.Conn, client Client) { +func (r *Registry) entry(id, login, host, commit string, now time.Time) *entry { + for k, e := range r.clients { + if e.conn == nil && now.Sub(e.seen) > expireAfter { + delete(r.clients, k) + } + } + e := r.clients[id] + if e == nil { + e = &entry{Client: Client{ID: id}} + r.clients[id] = e + } + e.Login = login + e.Host = host + e.Commit = commit + e.seen = now + return e +} + +func (r *Registry) Acted(id, login, host, commit string) bool { + if id == "" { + return false + } + r.mu.Lock() + defer r.mu.Unlock() + now := time.Now() + e := r.entry(id, login, host, commit, now) + e.LastActionAt = &now + if now.Sub(e.notified) < time.Second { + return false + } + e.notified = now + return true +} + +func (r *Registry) Serve(ctx context.Context, ws *websocket.Conn, id, login, host, commit string) { ws.SetReadLimit(readLimit) - c := &conn{Client: client, ws: ws, done: make(chan struct{}), pending: map[string]chan api.ClientReply{}} + c := &conn{ws: ws, done: make(chan struct{}), pending: map[string]chan api.ClientReply{}} r.mu.Lock() - old := r.conns[client.Host] - r.conns[client.Host] = c + now := time.Now().UTC() + e := r.entry(id, login, host, commit, now) + old := e.conn + e.conn = c + e.ConnectedAt = now r.mu.Unlock() if old != nil { - old.ws.Close(websocket.StatusGoingAway, "replaced by a newer connection from this host") + old.ws.Close(websocket.StatusGoingAway, "replaced by a newer connection from this client") } - slog.Info("client connected", "host", client.Host, "login", client.Login, "commit", client.Commit) + slog.Info("client connected", "id", id, "host", host, "login", login, "commit", commit) + r.changed() defer func() { r.mu.Lock() - if r.conns[client.Host] == c { - delete(r.conns, client.Host) + if e.conn == c { + e.conn = nil + e.seen = time.Now() } r.mu.Unlock() close(c.done) - slog.Info("client disconnected", "host", client.Host, "login", client.Login) + slog.Info("client disconnected", "id", id, "host", host, "login", login) + r.changed() }() ctx, cancel := context.WithCancel(ctx) @@ -98,7 +148,7 @@ func (r *Registry) Serve(ctx context.Context, ws *websocket.Conn, client Client) delete(c.pending, reply.Id) c.mu.Unlock() if ch == nil { - slog.Warn("client reply for no request", "host", client.Host, "id", reply.Id) + slog.Warn("client reply for no request", "host", host, "id", reply.Id) continue } ch <- reply @@ -128,25 +178,40 @@ func (r *Registry) List() []Client { r.mu.Lock() defer r.mu.Unlock() out := []Client{} - for _, c := range r.conns { - out = append(out, c.Client) + for _, e := range r.clients { + if e.conn != nil { + out = append(out, e.Client) + } } - sort.Slice(out, func(i, j int) bool { return out[i].Host < out[j].Host }) + sort.Slice(out, func(i, j int) bool { return out[i].ConnectedAt.Before(out[j].ConnectedAt) }) return out } -func (r *Registry) Call(ctx context.Context, host string, req api.ClientRequest) (api.ClientReply, error) { +func (r *Registry) Call(ctx context.Context, name string, req api.ClientRequest) (api.ClientReply, error) { r.mu.Lock() - c := r.conns[host] - hosts := []string{} - for h := range r.conns { - hosts = append(hosts, h) + matches := []*conn{} + ids := []string{} + connected := []string{} + for _, e := range r.clients { + if e.conn == nil { + continue + } + connected = append(connected, fmt.Sprintf("%s (%s)", e.Host, e.ID)) + if e.ID == name || e.Host == name { + matches = append(matches, e.conn) + ids = append(ids, e.ID) + } } r.mu.Unlock() - if c == nil { - slices.Sort(hosts) - return api.ClientReply{}, fmt.Errorf("no client named %q is connected; connected: %s", host, strings.Join(hosts, ", ")) + slices.Sort(connected) + if len(matches) == 0 { + return api.ClientReply{}, fmt.Errorf("no client named %q is connected; connected: %s", name, strings.Join(connected, ", ")) } + if len(matches) > 1 { + slices.Sort(ids) + return api.ClientReply{}, fmt.Errorf("%d clients on %s are connected; name one by its id: %s", len(matches), name, strings.Join(ids, ", ")) + } + c := matches[0] req.Id = rand.Text() ch := make(chan api.ClientReply, 1) @@ -165,7 +230,7 @@ func (r *Registry) Call(ctx context.Context, host string, req api.ClientRequest) } err = c.ws.Write(ctx, websocket.MessageText, data) if err != nil { - return api.ClientReply{}, fmt.Errorf("send to %s: %w", host, err) + return api.ClientReply{}, fmt.Errorf("send to %s: %w", name, err) } select { @@ -175,9 +240,9 @@ func (r *Registry) Call(ctx context.Context, host string, req api.ClientRequest) } return reply, nil case <-c.done: - return api.ClientReply{}, fmt.Errorf("%s disconnected before replying", host) + return api.ClientReply{}, fmt.Errorf("%s disconnected before replying", name) case <-ctx.Done(): - return api.ClientReply{}, fmt.Errorf("%s didn't reply: %w", host, ctx.Err()) + return api.ClientReply{}, fmt.Errorf("%s didn't reply: %w", name, ctx.Err()) } } diff --git a/internal/server/clients.go b/internal/server/clients.go index 9fb4062..a4d2736 100644 --- a/internal/server/clients.go +++ b/internal/server/clients.go @@ -10,17 +10,15 @@ import ( "os" "os/exec" "path/filepath" - "time" "github.com/coder/websocket" - - "g.fc.run/misc/byre/internal/clients" ) func (s *Server) ConnectClient(w http.ResponseWriter, r *http.Request) { + id := r.Header.Get("Byre-Client") host := r.Header.Get("Byre-Host") - if host == "" { - writeError(w, http.StatusBadRequest, errors.New("missing Byre-Host")) + if id == "" || host == "" { + writeError(w, http.StatusBadRequest, errors.New("missing Byre-Client or Byre-Host")) return } @@ -31,12 +29,7 @@ func (s *Server) ConnectClient(w http.ResponseWriter, r *http.Request) { } defer ws.CloseNow() - s.clients.Serve(r.Context(), ws, clients.Client{ - Login: actor(r), - Host: host, - Commit: r.Header.Get("Byre-Commit"), - ConnectedAt: time.Now().UTC(), - }) + s.clients.Serve(r.Context(), ws, id, actor(r), host, r.Header.Get("Byre-Commit")) } func (s *Server) GetClientTransfer(w http.ResponseWriter, r *http.Request, transferID string) { diff --git a/internal/server/hub.go b/internal/server/hub.go index dec24c6..b92be50 100644 --- a/internal/server/hub.go +++ b/internal/server/hub.go @@ -1,45 +1,25 @@ package server import ( - "sort" "sync" - "time" "g.fc.run/misc/byre/internal/api" ) -type clientKey struct{ login, host, commit string } - type hub struct { - mu sync.Mutex - subs map[chan api.Event]api.ConnectedClient - lastAction map[clientKey]time.Time - lastNotified map[clientKey]time.Time + mu sync.Mutex + subs map[chan api.Event]bool } func newHub() *hub { - return &hub{subs: map[chan api.Event]api.ConnectedClient{}, lastAction: map[clientKey]time.Time{}, lastNotified: map[clientKey]time.Time{}} + return &hub{subs: map[chan api.Event]bool{}} } -func (h *hub) acted(login, host, commit string) bool { - h.mu.Lock() - defer h.mu.Unlock() - k := clientKey{login, host, commit} - now := time.Now() - h.lastAction[k] = now - prev, ok := h.lastNotified[k] - if ok && now.Sub(prev) < time.Second { - return false - } - h.lastNotified[k] = now - return true -} - -func (h *hub) subscribe(client api.ConnectedClient) chan api.Event { +func (h *hub) subscribe() chan api.Event { h.mu.Lock() defer h.mu.Unlock() ch := make(chan api.Event, 256) - h.subs[ch] = client + h.subs[ch] = true return ch } @@ -72,27 +52,3 @@ func (h *hub) count() int { defer h.mu.Unlock() return len(h.subs) } - -func (h *hub) clients() []api.ConnectedClient { - h.mu.Lock() - defer h.mu.Unlock() - oldest := map[clientKey]api.ConnectedClient{} - for _, c := range h.subs { - k := clientKey{c.Login, c.Host, c.Commit} - prev, ok := oldest[k] - if ok && !c.ConnectedAt.Before(prev.ConnectedAt) { - continue - } - oldest[k] = c - } - out := []api.ConnectedClient{} - for k, c := range oldest { - at, ok := h.lastAction[k] - if ok { - c.LastActionAt = &at - } - out = append(out, c) - } - sort.Slice(out, func(i, j int) bool { return out[i].ConnectedAt.Before(out[j].ConnectedAt) }) - return out -} diff --git a/internal/server/server.go b/internal/server/server.go index 91cdffc..14c41ec 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -45,11 +45,11 @@ func New(dataDir string, portFirst int) (*Server, error) { s := &Server{ hub: newHub(), - clients: clients.New(), terminals: newTerminals(), startedAt: time.Now().UTC(), users: map[string]string{}, } + s.clients = clients.New(s.clientsChanged) s.store, err = store.Open(filepath.Join(dataDir, "byre.db"), portFirst, s.publish) if err != nil { @@ -145,11 +145,26 @@ func (s *Server) identify(next http.Handler) http.Handler { } func (s *Server) acted(login string, r *http.Request) { - if s.hub.acted(login, r.Header.Get("Byre-Host"), r.Header.Get("Byre-Commit")) { + if s.clients.Acted(r.Header.Get("Byre-Client"), login, r.Header.Get("Byre-Host"), r.Header.Get("Byre-Commit")) { s.clientsChanged() } } +func (s *Server) connectedClients() []api.ConnectedClient { + out := []api.ConnectedClient{} + for _, c := range s.clients.List() { + out = append(out, api.ConnectedClient{ + Id: c.ID, + Login: c.Login, + Host: c.Host, + Commit: c.Commit, + ConnectedAt: c.ConnectedAt, + LastActionAt: c.LastActionAt, + }) + } + return out +} + func (s *Server) clientsChanged() { s.store.Broadcast(api.Event{Kind: "admin.clients", Actor: "byre", Payload: map[string]any{}}) } @@ -638,7 +653,7 @@ func (s *Server) status(ctx context.Context) (api.Status, error) { Projects: c.Projects, ActiveTasks: c.ActiveTasks, SseClients: s.hub.count(), - Clients: s.hub.clients(), + Clients: s.connectedClients(), LiveAgents: s.agents.LiveCount(), RunningTurns: usage.Running, RateLimits: usage.RateLimits, diff --git a/internal/server/sse.go b/internal/server/sse.go index 6eae96f..5529c86 100644 --- a/internal/server/sse.go +++ b/internal/server/sse.go @@ -55,12 +55,7 @@ func (s *Server) StreamEvents(w http.ResponseWriter, r *http.Request, params api w.Header().Set("Cache-Control", "no-cache") w.WriteHeader(http.StatusOK) - live := s.hub.subscribe(api.ConnectedClient{ - Login: viewer, - Host: r.Header.Get("Byre-Host"), - Commit: r.Header.Get("Byre-Commit"), - ConnectedAt: time.Now(), - }) + live := s.hub.subscribe() defer s.clientsChanged() defer s.hub.unsubscribe(live) s.clientsChanged() diff --git a/mac/Sources/Byre/API.swift b/mac/Sources/Byre/API.swift index d61edfb..660c11f 100644 --- a/mac/Sources/Byre/API.swift +++ b/mac/Sources/Byre/API.swift @@ -95,6 +95,8 @@ func errorText(_ error: any Error) -> String { return String(describing: e) } +private let clientID = UUID().uuidString + final class API: Sendable { let origin: URL let session: URLSession @@ -104,6 +106,7 @@ final class API: Sendable { self.origin = origin let config = URLSessionConfiguration.default config.httpAdditionalHeaders = [ + "Byre-Client": clientID, "Byre-Commit": Bundle.main.object(forInfoDictionaryKey: "ByreCommit") as! String, "Byre-Host": Host.current().localizedName ?? ProcessInfo.processInfo.hostName, ] diff --git a/mac/Sources/Byre/Remote.swift b/mac/Sources/Byre/Remote.swift index 19f3ab3..38e0eaf 100644 --- a/mac/Sources/Byre/Remote.swift +++ b/mac/Sources/Byre/Remote.swift @@ -7,6 +7,8 @@ typealias ClientRequest = Components.Schemas.ClientRequest typealias ClientReply = Components.Schemas.ClientReply private let retryAfter = Duration.seconds(2) +private let checkEvery = Duration.seconds(10) +private let staleAfter: TimeInterval = 25 private let maxOutput = 64 << 10 private let maxMessage = 256 << 20 private let maxRead = 64 << 20 @@ -18,6 +20,8 @@ final class Remote { private let hands = Hands() private var ws: URLSessionWebSocketTask? private var stopped = false + private var heard = Date() + private var checker: Task? init(api: API) { self.api = api @@ -30,12 +34,22 @@ final class Remote { let task = api.session.webSocketTask(with: api.url("/api/clients/connect", scheme: "wss")) task.maximumMessageSize = maxMessage ws = task + heard = Date() task.resume() receive(task) + if checker == nil { + checker = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: checkEvery) + self?.check() + } + } + } } func stop() { stopped = true + checker?.cancel() ws?.cancel(with: .normalClosure, reason: nil) ws = nil Task { [hands] in @@ -43,6 +57,30 @@ final class Remote { } } + // A socket byre closed while this Mac slept stays open here until something is sent on it. + private func check() { + guard let task = ws else { + return + } + if Date().timeIntervalSince(heard) >= staleAfter { + log.notice("client connection got no pong for \(Int(staleAfter))s, reconnecting") + ws = nil + task.cancel(with: .goingAway, reason: nil) + start() + return + } + task.sendPing { error in + guard error == nil else { + return + } + Task { @MainActor in + if self.ws === task { + self.heard = Date() + } + } + } + } + private func receive(_ task: URLSessionWebSocketTask) { task.receive { result in Task { @MainActor in @@ -59,7 +97,7 @@ final class Remote { case .success: self.receive(task) case .failure(let error): - log.info("client connection closed: \(errorText(error), privacy: .public)") + log.notice("client connection closed:\(errorText(error), privacy: .public)") self.ws = nil try? await Task.sleep(for: retryAfter) self.start()