[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
278
components/terminal/terminalRuntimeMount.ts
Normal file
278
components/terminal/terminalRuntimeMount.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import type { MutableRefObject } from "react";
|
||||
import type { FitAddon } from "@xterm/addon-fit";
|
||||
import type { SearchAddon } from "@xterm/addon-search";
|
||||
import type { SerializeAddon } from "@xterm/addon-serialize";
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
|
||||
import { logger } from "../../lib/logger";
|
||||
import type { TerminalHibernateWakePayload } from "../../domain/terminalHibernate";
|
||||
import {
|
||||
createXTermRuntime,
|
||||
type CreateXTermRuntimeContext,
|
||||
type XTermRuntime,
|
||||
} from "./runtime/createXTermRuntime";
|
||||
import {
|
||||
appendTerminalReplayData,
|
||||
applyHibernateWakeToTerminal,
|
||||
nudgeAlternateScreenRedraw,
|
||||
} from "./terminalHibernateRuntime";
|
||||
import {
|
||||
applyTerminalKeywordHighlightRules,
|
||||
type AdditionalTerminalKeywordHighlightRule,
|
||||
} from "./terminalKeywordHighlightRules";
|
||||
|
||||
export { applyTerminalKeywordHighlightRules } from "./terminalKeywordHighlightRules";
|
||||
|
||||
export type TerminalRuntimeRefs = {
|
||||
xtermRuntimeRef: MutableRefObject<XTermRuntime | null>;
|
||||
termRef: MutableRefObject<XTerm | null>;
|
||||
fitAddonRef: MutableRefObject<FitAddon | null>;
|
||||
serializeAddonRef: MutableRefObject<SerializeAddon | null>;
|
||||
searchAddonRef: MutableRefObject<SearchAddon | null>;
|
||||
hasRuntimeRef: MutableRefObject<boolean>;
|
||||
};
|
||||
|
||||
export function assignTerminalRuntimeRefs(
|
||||
refs: TerminalRuntimeRefs,
|
||||
runtime: XTermRuntime,
|
||||
): void {
|
||||
refs.xtermRuntimeRef.current = runtime;
|
||||
refs.termRef.current = runtime.term;
|
||||
refs.fitAddonRef.current = runtime.fitAddon;
|
||||
refs.serializeAddonRef.current = runtime.serializeAddon;
|
||||
refs.searchAddonRef.current = runtime.searchAddon;
|
||||
refs.hasRuntimeRef.current = true;
|
||||
}
|
||||
|
||||
export type WakeTerminalFromHibernateOptions = {
|
||||
refs: TerminalRuntimeRefs;
|
||||
runtimeContext: Omit<CreateXTermRuntimeContext, "container" | "initiallyVisible" | "deferWebglUntilReplayComplete">;
|
||||
container: HTMLDivElement;
|
||||
getPayload: () => TerminalHibernateWakePayload;
|
||||
/**
|
||||
* Pause backend output and wait for in-flight chunks to drain into the
|
||||
* hibernate pending buffer before history replay. Returns true when the
|
||||
* drain succeeded (safe to detach the data listener). Returns false when
|
||||
* pause/drain was best-effort only; caller must keep a live uncapped
|
||||
* pending listener through history replay (#2762).
|
||||
*/
|
||||
prepareWakeFlow?: () => Promise<boolean>;
|
||||
/**
|
||||
* Atomically read and clear hibernate pending output after the data listener
|
||||
* has been stopped (and preferably after prepareWakeFlow).
|
||||
*/
|
||||
takePendingBuffer: () => string;
|
||||
/** Stop only the hibernate data listener so pending stops growing. */
|
||||
stopHibernateDataListener: () => void;
|
||||
/**
|
||||
* When prepareWakeFlow returns false, disable the 512 KiB pending cap so
|
||||
* ACKed bytes that arrive during history replay are not trimmed.
|
||||
*/
|
||||
setHibernatePendingCapDisabled?: (disabled: boolean) => void;
|
||||
/**
|
||||
* Stop hibernate data+exit listeners and clear flow-ack state.
|
||||
* Must keep the backend paused until resumeAfterReattach runs.
|
||||
*/
|
||||
stopHibernateListeners: () => void;
|
||||
/**
|
||||
* On a thrown/failed wake: dispose the partial xterm runtime and restore
|
||||
* hibernate listeners so output is ACKed again (never unpause without a
|
||||
* listener). `takenPending` is every byte already take-and-cleared during
|
||||
* this wake attempt; it must be restored because those bytes no longer
|
||||
* live in the pending ref and the partial xterm is disposed.
|
||||
*/
|
||||
restoreAfterFailedWake?: (takenPending: string) => void;
|
||||
/** Resume backend output after the live display listener is attached. */
|
||||
resumeAfterReattach?: () => void;
|
||||
reattachSession: (term: XTerm) => void;
|
||||
safeFit: (options?: { force?: boolean; requireVisible?: boolean }) => void;
|
||||
resizeSession: () => void;
|
||||
forceSyncRenderAfterResize: (term: XTerm) => void;
|
||||
lastFittedSizeRef: MutableRefObject<{ width: number; height: number } | null>;
|
||||
isBootActiveRef: MutableRefObject<boolean>;
|
||||
sessionId: string;
|
||||
updateStatus: (status: "connected") => void;
|
||||
/** When false, recreate xterm and replay output without reattaching or forcing connected status. */
|
||||
sessionConnected?: boolean;
|
||||
getSessionConnected?: () => boolean;
|
||||
replayChunkBytes?: number;
|
||||
additionalKeywordHighlightRules?: readonly AdditionalTerminalKeywordHighlightRule[];
|
||||
};
|
||||
|
||||
export async function wakeTerminalFromHibernate(
|
||||
options: WakeTerminalFromHibernateOptions,
|
||||
): Promise<boolean> {
|
||||
const {
|
||||
refs,
|
||||
runtimeContext,
|
||||
container,
|
||||
getPayload,
|
||||
prepareWakeFlow,
|
||||
takePendingBuffer,
|
||||
stopHibernateDataListener,
|
||||
setHibernatePendingCapDisabled,
|
||||
stopHibernateListeners,
|
||||
restoreAfterFailedWake,
|
||||
resumeAfterReattach,
|
||||
reattachSession,
|
||||
safeFit,
|
||||
resizeSession,
|
||||
forceSyncRenderAfterResize,
|
||||
lastFittedSizeRef,
|
||||
isBootActiveRef,
|
||||
sessionId,
|
||||
updateStatus,
|
||||
sessionConnected = true,
|
||||
getSessionConnected,
|
||||
replayChunkBytes = 16 * 1024,
|
||||
additionalKeywordHighlightRules = Object.freeze([]),
|
||||
} = options;
|
||||
|
||||
if (refs.hasRuntimeRef.current) {
|
||||
return true;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
||||
return;
|
||||
}
|
||||
window.setTimeout(resolve, 0);
|
||||
});
|
||||
|
||||
isBootActiveRef.current = true;
|
||||
lastFittedSizeRef.current = null;
|
||||
|
||||
const runtime = createXTermRuntime({
|
||||
...runtimeContext,
|
||||
container,
|
||||
initiallyVisible: true,
|
||||
deferWebglUntilReplayComplete: true,
|
||||
});
|
||||
|
||||
assignTerminalRuntimeRefs(refs, runtime);
|
||||
applyTerminalKeywordHighlightRules(
|
||||
runtime,
|
||||
runtimeContext.terminalSettingsRef,
|
||||
runtimeContext.host,
|
||||
additionalKeywordHighlightRules,
|
||||
);
|
||||
|
||||
const term = runtime.term;
|
||||
// Pause first so in-flight output drains into pending via the still-live
|
||||
// hibernate listener, then detach that listener before capturing pending.
|
||||
// Full-history replay can take many frames; leaving the capped pending
|
||||
// buffer open (or leaving no display listener while flow is live) drops
|
||||
// ACKed bytes under sustained `cat` output (#2762).
|
||||
let didReattach = false;
|
||||
let wakeSucceeded = false;
|
||||
// Bytes take-and-cleared from the pending ref during this wake. On failure
|
||||
// they must be handed back: the partial xterm is disposed and the ref no
|
||||
// longer holds them.
|
||||
let takenPendingForRestore = "";
|
||||
const takeAndTrackPending = (): string => {
|
||||
const pending = takePendingBuffer();
|
||||
if (pending) takenPendingForRestore += pending;
|
||||
return pending;
|
||||
};
|
||||
try {
|
||||
const drainOk = (await prepareWakeFlow?.()) ?? true;
|
||||
if (drainOk) {
|
||||
stopHibernateDataListener();
|
||||
} else {
|
||||
// Drain timed out / unavailable: keep the hibernate data listener so
|
||||
// in-flight ACKed bytes still land in pending, but disable the 512 KiB
|
||||
// cap for the wake window so a busy `cat` cannot trim the front.
|
||||
setHibernatePendingCapDisabled?.(true);
|
||||
}
|
||||
const initialPayload = getPayload();
|
||||
const pendingAtApplyStart = takeAndTrackPending();
|
||||
const replayOptions = { chunkBytes: replayChunkBytes };
|
||||
let replayedPendingChars = pendingAtApplyStart.length;
|
||||
|
||||
await applyHibernateWakeToTerminal(term, runtime, {
|
||||
...initialPayload,
|
||||
pendingBuffer: pendingAtApplyStart,
|
||||
}, {
|
||||
replayOptions,
|
||||
deferWebgl: true,
|
||||
});
|
||||
runtime.cursorLineHighlighter.refresh({ force: true });
|
||||
|
||||
// Stop the data listener before draining residual pending. Captures:
|
||||
// - exit "[session closed]" tails appended during history replay
|
||||
// - uncapped live arrivals when drainOk was false
|
||||
// Only the exit listener can still append after this point, so a short
|
||||
// until-empty loop is enough; the last take before teardown must be empty
|
||||
// after any awaited replay.
|
||||
stopHibernateDataListener();
|
||||
setHibernatePendingCapDisabled?.(false);
|
||||
for (;;) {
|
||||
const pendingTail = takeAndTrackPending();
|
||||
if (!pendingTail) break;
|
||||
await appendTerminalReplayData(term, pendingTail, replayOptions);
|
||||
replayedPendingChars += pendingTail.length;
|
||||
}
|
||||
|
||||
// Recompute after drains so an exit during those awaits cannot leave
|
||||
// shouldReattach stale (would reattach a dead session as connected).
|
||||
const shouldReattach = sessionConnected && (getSessionConnected?.() ?? true);
|
||||
stopHibernateListeners();
|
||||
if (shouldReattach) {
|
||||
reattachSession(term);
|
||||
updateStatus("connected");
|
||||
didReattach = true;
|
||||
}
|
||||
|
||||
runtime.ensureWebglRenderer();
|
||||
runtime.clearTextureAtlas();
|
||||
|
||||
safeFit({ force: true });
|
||||
resizeSession();
|
||||
forceSyncRenderAfterResize(term);
|
||||
if (initialPayload.alternateScreen) {
|
||||
nudgeAlternateScreenRedraw(term);
|
||||
} else {
|
||||
term.scrollToBottom();
|
||||
}
|
||||
|
||||
window.setTimeout(() => safeFit({ force: true }), 0);
|
||||
window.setTimeout(() => {
|
||||
safeFit({ force: true });
|
||||
forceSyncRenderAfterResize(term);
|
||||
if (initialPayload.alternateScreen) {
|
||||
nudgeAlternateScreenRedraw(term);
|
||||
}
|
||||
}, 100);
|
||||
window.setTimeout(() => {
|
||||
safeFit({ force: true });
|
||||
forceSyncRenderAfterResize(term);
|
||||
if (initialPayload.alternateScreen) {
|
||||
nudgeAlternateScreenRedraw(term);
|
||||
}
|
||||
}, 350);
|
||||
|
||||
logger.info("[Terminal] Resumed from hibernate", {
|
||||
sessionId,
|
||||
snapshotChars: initialPayload.snapshot.length,
|
||||
viewportChars: initialPayload.viewportSnapshot?.length ?? initialPayload.snapshot.length,
|
||||
scrollbackChars: initialPayload.scrollbackSnapshot?.length ?? 0,
|
||||
pendingChars: replayedPendingChars,
|
||||
alternateScreen: initialPayload.alternateScreen,
|
||||
});
|
||||
wakeSucceeded = true;
|
||||
return true;
|
||||
} finally {
|
||||
setHibernatePendingCapDisabled?.(false);
|
||||
if (!wakeSucceeded) {
|
||||
// Dispose the partial runtime and restore hibernate listeners so a
|
||||
// failed wake never unpauses into a listener-less backlog gap, and so a
|
||||
// retry can re-enter wake (hasRuntimeRef stays false). Hand back every
|
||||
// take-and-cleared pending byte so it is not lost with the disposed term.
|
||||
restoreAfterFailedWake?.(takenPendingForRestore);
|
||||
} else if (didReattach) {
|
||||
resumeAfterReattach?.();
|
||||
}
|
||||
// Successful reconnect wakes (!didReattach) keep the pause until cleanupSession.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user