This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/web/src/connections.ts
T
Miguel PalhasandClaude Opus 5 f31377c34f
ci / web (pull_request) Successful in 28s
ci / rust (pull_request) Successful in 1m58s
ci / web (push) Successful in 34s
ci / rust (push) Successful in 2m44s
fix(maestro): three defects the review flagged and left open
SPA attach had no reconnect at all. Any dropped frame closed the socket,
`onClosed` fired, and the pane was destroyed permanently — the TUI
reconnects forever on the same endpoint. Session death also reached the UI
on two uncoordinated channels: the row from the events socket, the pane
from `ws.onclose`, with nothing reconciling them, so either could happen
without the other.

The catalog is now the authority. `shouldRetryClose` decides from session
status: Running means the link dropped, so retry with 500ms-to-10s
backoff; Dead means the PTY stream was sealed and nothing more is coming;
absent means it is gone. Application closes (4000+, a relayed daemon
rejection) and clean closes are never retried. The shell also closes any
pane whose session has left the catalog. The policy lives in reconnect.ts
so it is tested without a DOM.

kill_session checked `has()` before `kill()`, which only opened a window
for the liveness watcher to reap in between — turning a 404 into a 502.
`kill` reports the miss itself.

with_spawn_history logged and carried on when `Arc::get_mut` failed,
producing a daemon whose recent-dirs picker is permanently empty with
nothing to show for it. It panics now: that is a construction-order
mistake, at startup, in code that runs once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 16:31:54 +01:00

175 lines
5.8 KiB
TypeScript

import {
createAttachConnection,
type AttachConnection,
type AttachInfo,
} from "./attach.js";
import type { Uuid } from "./types.js";
/**
* How many *parked* (off-screen but still-connected) sessions to keep warm
* beyond whatever is currently visible. Visible panes are never counted or
* evicted — only this background buffer is bounded, so the browser doesn't hold
* an unbounded fan of open sockets. Switching back to a recently-viewed session
* is instant while it's still parked; older ones are evicted (WS closed) once
* the buffer overflows.
*/
const MAX_PARKED = 6;
export interface PoolOptions {
/** Fired when the focused session changes (drives sidebar highlight + HUD). */
onFocusChange?: (id: Uuid | null) => void;
/** Fired when a view's explicit disconnect control is used; the embedder
* decides how to update the layout/route (then typically calls `close`). */
onRequestClose?: (id: Uuid) => void;
/** Fired when a connection's socket closes on its own (session ended/dropped),
* not via an intentional close/eviction. The embedder closes the pane. */
onClosed?: (id: Uuid) => void;
/** Forwarded to each connection: should a dropped socket be retried? */
shouldReconnect?: (id: Uuid, code: number) => boolean;
}
/**
* Owns the set of live attach connections, decoupled from the router/UI.
*
* Unlike the old single-active pool, several connections can be **mounted**
* (visible) at once — one per leaf of the active tab's layout tree. The shell
* reconciles the visible set with {@link setVisible}; sessions no longer shown
* are **parked** in a hidden cache (still streaming) and bounded by an LRU. A
* connection only dies on eviction, an explicit {@link close}, or {@link dispose}.
*/
export class ConnectionPool {
/** All live connections. Insertion order doubles as LRU: oldest first. */
private readonly cache = new Map<Uuid, AttachConnection>();
/** Sessions currently mounted into a visible pane (never evicted). */
private readonly mounted = new Set<Uuid>();
private readonly hidden: HTMLElement;
private focusedId: Uuid | null = null;
private readonly opts: PoolOptions;
constructor(opts: PoolOptions = {}) {
this.opts = opts;
// Off-screen home for parked, still-connected terminals.
this.hidden = document.createElement("div");
this.hidden.className = "conn-cache";
this.hidden.setAttribute("aria-hidden", "true");
document.body.appendChild(this.hidden);
}
/** The focused session, or null when nothing is focused (welcome pane). */
focused(): Uuid | null {
return this.focusedId;
}
/** Back-compat alias for {@link focused} (used by the debug HUD). */
active(): Uuid | null {
return this.focusedId;
}
isMounted(id: Uuid): boolean {
return this.mounted.has(id);
}
/**
* Ensure `id` is live and shown inside `pane`, reusing its connection if one
* exists (refreshing LRU recency). Does not touch other mounted panes.
*/
mount(id: Uuid, pane: HTMLElement, info?: AttachInfo): void {
let conn = this.cache.get(id);
if (conn) {
this.cache.delete(id);
this.cache.set(id, conn);
} else {
conn = createAttachConnection(
id,
{
onRequestClose: (sid) => this.opts.onRequestClose?.(sid),
onClosed: (sid) => this.opts.onClosed?.(sid),
shouldReconnect: (sid, code) => this.opts.shouldReconnect?.(sid, code) ?? false,
},
info,
);
this.cache.set(id, conn);
}
if (conn.el.parentElement !== pane) {
pane.replaceChildren(conn.el);
conn.onMount();
} else if (!this.mounted.has(id)) {
conn.onMount();
}
this.mounted.add(id);
this.evict();
}
/** Park `id` back into the hidden cache (still connected) and stop showing it. */
unmount(id: Uuid): void {
if (!this.mounted.delete(id)) return;
const conn = this.cache.get(id);
if (conn) {
this.hidden.appendChild(conn.el); // stays alive, just off-screen
conn.onUnmount();
}
}
/**
* Reconcile the visible set to exactly `entries` (in render order): park any
* mounted session not listed, mount/refresh each listed one in its pane.
*/
setVisible(entries: { id: Uuid; pane: HTMLElement; info?: AttachInfo }[]): void {
const next = new Set(entries.map((e) => e.id));
for (const id of [...this.mounted]) {
if (!next.has(id)) this.unmount(id);
}
for (const e of entries) this.mount(e.id, e.pane, e.info);
}
setFocus(id: Uuid | null): void {
if (id === this.focusedId) return;
this.focusedId = id;
this.opts.onFocusChange?.(id);
}
/** Compat shim: show `id` alone in `pane` and focus it; park everything else. */
activate(id: Uuid, pane: HTMLElement, info?: AttachInfo): void {
this.setVisible([{ id, pane, info }]);
this.setFocus(id);
}
/** Compat shim: park all visible panes and clear focus (e.g. welcome pane). */
parkActive(): void {
for (const id of [...this.mounted]) this.unmount(id);
this.setFocus(null);
}
/** Fully disconnect + forget a session. */
close(id: Uuid): void {
const conn = this.cache.get(id);
if (!conn) return;
conn.dispose();
this.cache.delete(id);
this.mounted.delete(id);
if (this.focusedId === id) this.setFocus(null);
}
dispose(): void {
for (const conn of this.cache.values()) conn.dispose();
this.cache.clear();
this.mounted.clear();
this.hidden.remove();
}
/** Drop least-recently-viewed *parked* connections beyond the buffer cap. */
private evict(): void {
while (this.cache.size - this.mounted.size > MAX_PARKED) {
let victim: Uuid | null = null;
for (const id of this.cache.keys()) {
if (!this.mounted.has(id)) {
victim = id;
break;
}
}
if (victim === null) break;
this.close(victim);
}
}
}