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>
175 lines
5.8 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|