Integration review (#168): a rejected attach handshake — a dormant session's 409 not_attachable, most likely — looks identical to a dropped stream from the WebSocket API (close code 1006, no reason). shouldRetryClose kept retrying it on the catalog's Running/Dead check alone, which loops for as long as the catalog hasn't caught up with the session going dormant. Track whether the socket ever reached `open`; a handshake that never did is a rejection, not a blip, so it is never retried regardless of catalog status. The caller (wired up in the next commit) gets a distinct onNeverAttached callback instead of onClosed, so it can offer the resume affordance rather than assuming the session is gone.
213 lines
7.6 KiB
TypeScript
213 lines
7.6 KiB
TypeScript
import {
|
|
createAttachConnection,
|
|
type AttachConnection,
|
|
type AttachInfo,
|
|
} from "./attach.js";
|
|
import { setSessionFocus } from "./api.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;
|
|
/** Fired when a connection's handshake never completed and so was never
|
|
* retried (see `AttachOptions.onNeverAttached`). Unlike `onClosed`, the
|
|
* embedder should not assume the session is gone — a dormant session's
|
|
* pane wants its resume affordance, not teardown. */
|
|
onNeverAttached?: (id: Uuid) => void;
|
|
/** Forwarded to each connection: should a dropped socket be retried? */
|
|
shouldReconnect?: (id: Uuid, code: number, everOpened: boolean) => 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, opts?: { focus?: boolean }): 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),
|
|
onNeverAttached: (sid) => this.opts.onNeverAttached?.(sid),
|
|
shouldReconnect: (sid, code, everOpened) =>
|
|
this.opts.shouldReconnect?.(sid, code, everOpened) ?? false,
|
|
},
|
|
info,
|
|
);
|
|
this.cache.set(id, conn);
|
|
}
|
|
if (conn.el.parentElement !== pane) {
|
|
pane.replaceChildren(conn.el);
|
|
conn.onMount(opts);
|
|
} else if (!this.mounted.has(id)) {
|
|
conn.onMount(opts);
|
|
}
|
|
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.
|
|
* `focus: false` shows the panes without taking the keyboard (preview).
|
|
*/
|
|
setVisible(
|
|
entries: { id: Uuid; pane: HTMLElement; info?: AttachInfo }[],
|
|
opts?: { focus?: boolean },
|
|
): 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, opts);
|
|
}
|
|
|
|
/** Also tells the daemon which pane is focused (docs/SIDEBAR.md's
|
|
* `AwaitingInput` auto-clear) — the sidebar highlight never calls this,
|
|
* only a pane actually becoming the visible view. Best-effort: a failed
|
|
* report only delays the glow clearing. */
|
|
setFocus(id: Uuid | null): void {
|
|
if (id === this.focusedId) return;
|
|
const previous = this.focusedId;
|
|
this.focusedId = id;
|
|
if (previous !== null) void setSessionFocus(previous, false).catch(() => {});
|
|
if (id !== null) void setSessionFocus(id, true).catch(() => {});
|
|
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);
|
|
}
|
|
|
|
/** Put the keyboard back into the focused session's terminal. No-op when
|
|
* nothing is focused (welcome pane) or its connection is gone. */
|
|
focusTerminal(): void {
|
|
if (this.focusedId === null) return;
|
|
this.cache.get(this.focusedId)?.focusTerminal();
|
|
}
|
|
|
|
/** Type raw bytes into the focused session's PTY. Returns false when there is
|
|
* nothing focused, or its connection is gone — the leader's literal Ctrl-K
|
|
* is a no-op over the welcome pane rather than an error
|
|
* (docs/COMMAND-BAR.md). */
|
|
sendToFocused(data: string): boolean {
|
|
if (this.focusedId === null) return false;
|
|
const conn = this.cache.get(this.focusedId);
|
|
if (!conn) return false;
|
|
conn.sendInput(data);
|
|
return true;
|
|
}
|
|
|
|
/** 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);
|
|
}
|
|
}
|
|
}
|