mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Merge pull request #431 from rounakdatta/feat/mobile-terminal-resilience
fix(terminal): four silent-failure paths — renderer freeze, replay race, reconnect gap, unbounded fetches
This commit is contained in:
@@ -4098,6 +4098,29 @@ export class Session extends EventEmitter {
|
||||
private _ptyCols = 120;
|
||||
private _ptyRows = 40;
|
||||
|
||||
/**
|
||||
* The geometry the CLI is actually drawing for, or null when nothing is
|
||||
* drawing.
|
||||
*
|
||||
* Exposed because `resize()` can decline a request outright (arbitration
|
||||
* below) and the asking client has no other way to find out: a browser
|
||||
* terminal that keeps a shape the PTY refused renders garbled output rather
|
||||
* than wrong-sized output, because Claude Code's repaints are computed from
|
||||
* the width it was told (issue #464). Both transports report this back.
|
||||
*
|
||||
* ⚠️ NULL WITHOUT A PANE, never the field values. `resize()` writes
|
||||
* `_ptyCols`/`_ptyRows` only when `ptyProcess` is set, and nothing seeds them
|
||||
* from the spawn geometry, so a session with a dead pane — or one created
|
||||
* through the API and never started — still holds the constructor defaults
|
||||
* of 120x40. Reporting those made a client adopt a size no process had ever
|
||||
* been told, and on anything narrower than 120 columns it claimed another
|
||||
* device owned the pane when none existed. `reconcilePtyGeometry` treats a
|
||||
* report with no finite numbers as no evidence, which is the truth here.
|
||||
*/
|
||||
get ptyGeometry(): { cols: number; rows: number } | null {
|
||||
return this.ptyProcess ? { cols: this._ptyCols, rows: this._ptyRows } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live WebSocket connections that have announced a desktop viewport for this
|
||||
* session. While at least one is registered, small-viewport (mobile/tablet)
|
||||
@@ -4183,6 +4206,10 @@ export class Session extends EventEmitter {
|
||||
}
|
||||
if (isSmallViewport && this._desktopSizeClaims.size > 0) {
|
||||
if (Date.now() - this._lastDesktopActivityAt < Session.DESKTOP_CLAIM_IDLE_MS) {
|
||||
// Declined. The caller is told nothing here on purpose — the decision
|
||||
// belongs to the session, not the socket — but the caller MUST report
|
||||
// `ptyCols`/`ptyRows` back afterwards so the asking client can adopt
|
||||
// the shape it did not get. Both transports do; see issue #464.
|
||||
return;
|
||||
}
|
||||
this._mobileSizeOverride = true;
|
||||
|
||||
+306
-45
@@ -66,7 +66,23 @@ const _crashDiag = {
|
||||
// concurrent clients (desktop + phone) don't clobber each other.
|
||||
_pageId: Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8),
|
||||
log(msg) {
|
||||
const entry = `${new Date().toISOString().slice(11,23)} ${msg}`;
|
||||
// Entries are joined with '\n' into ONE localStorage value and beaconed to
|
||||
// the server, and some call sites interpolate text this client does not
|
||||
// control (a WebSocket close `reason` arrives from the server). A newline
|
||||
// in there forges extra entries in the trail; an unbounded string can fill
|
||||
// the storage quota and silently kill every later breadcrumb. Flatten and
|
||||
// cap. CodemanDiag is loaded before app.js, but guard anyway — a
|
||||
// diagnostic that can throw is worse than no diagnostic.
|
||||
// Bound to a local FIRST: `CodemanDiag?.x` still throws a ReferenceError
|
||||
// when the identifier was never declared, and this is the one function in
|
||||
// the app that must never throw.
|
||||
const diag = typeof CodemanDiag !== 'undefined' ? CodemanDiag : null;
|
||||
const flat = diag?.sanitizeDiagEntry
|
||||
? diag.sanitizeDiagEntry(msg)
|
||||
: String(msg == null ? '' : msg)
|
||||
.replace(/[\r\n\u2028\u2029]+/g, ' ')
|
||||
.slice(0, diag?.DIAG_ENTRY_MAX_CHARS ?? 300);
|
||||
const entry = `${new Date().toISOString().slice(11,23)} ${flat}`;
|
||||
this._entries.push(entry);
|
||||
if (this._entries.length > this._maxEntries) this._entries.shift();
|
||||
try { localStorage.setItem('codeman-crash-diag', this._entries.join('\n')); } catch {}
|
||||
@@ -731,6 +747,10 @@ class CodemanApp {
|
||||
this._wsReady = false; // True when WS is open and ready for I/O
|
||||
this._wsState = 'disconnected'; // connecting | connected | reconnecting | fallback | disconnected
|
||||
this._wsLastRecvAt = 0; // ms timestamp of the last frame received on the active WS
|
||||
// Session whose socket dropped unintentionally, so output produced during
|
||||
// the outage is missing from its buffer. Output frames carry no sequence
|
||||
// number, so the only recovery is to refetch on the next successful open.
|
||||
this._wsOutputGapSession = null;
|
||||
|
||||
// Terminal write batching with DEC 2026 sync support
|
||||
this.pendingWrites = [];
|
||||
@@ -2485,8 +2505,14 @@ class CodemanApp {
|
||||
// placeholder beats a messy screen dump there.
|
||||
const sessionMode = this.sessions.get(this.activeSessionId)?.mode || 'claude';
|
||||
if (!lastResponse && (sessionMode === 'claude' || sessionMode === 'shell')) {
|
||||
const termRes = await fetch(`/api/sessions/${this.activeSessionId}/terminal`);
|
||||
const termData = (await termRes.json())?.data ?? {};
|
||||
// The no-param form is capped only by `terminalBufferMaxBytes` (32MB by
|
||||
// default), so it is the largest body the frontend asks for anywhere —
|
||||
// it gets the full-history budget, not the tail one.
|
||||
const termCapture = await this._fetchTerminalCapture(
|
||||
`/api/sessions/${this.activeSessionId}/terminal`,
|
||||
{ full: true }
|
||||
);
|
||||
const termData = termCapture.json?.data ?? {};
|
||||
if (termData.terminalBuffer) {
|
||||
lastResponse = this._cleanTerminalBuffer(termData.terminalBuffer);
|
||||
}
|
||||
@@ -2604,6 +2630,81 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a terminal capture under a deadline.
|
||||
*
|
||||
* Every terminal fetch used to run with no timeout at all, including
|
||||
* `?full=1`, which _maybeRefetchFullHistory itself calls "unbounded-ish work:
|
||||
* at the default history limit it can be megabytes". On a stalled mobile link
|
||||
* that request hangs on the browser default with no retry, and the load-state
|
||||
* machinery stays armed behind it.
|
||||
*
|
||||
* The budget scales with what is being asked for and with how many captures
|
||||
* are already running (see CodemanFetchDeadline): a full scrollback on a slow
|
||||
* uplink legitimately needs longer than a tail, and eight tabs resuming must
|
||||
* not all expire together because each assumed it had the link to itself.
|
||||
*
|
||||
* An abort surfaces as a rejected promise, which every caller already handles —
|
||||
* they wrap these in try/catch and log. That is the point: a timeout becomes a
|
||||
* recoverable error instead of an indefinite hang.
|
||||
*
|
||||
* ⚠️ **The body is read HERE, and that is the whole point.** `await fetch()`
|
||||
* settles on response HEADERS, not the body, so clearing the deadline when it
|
||||
* resolves leaves the body — the multi-megabyte `?full=1` capture this exists
|
||||
* for — completely unbounded. Measured against a server that sends headers
|
||||
* immediately and stalls the body: `fetch()` resolved at 30ms, the timer was
|
||||
* cleared there, and the body completed at 4026ms unaborted under a 1000ms
|
||||
* deadline. Reading the body inside the helper is what makes the deadline
|
||||
* cover the transfer rather than just the handshake. `_terminalCaptureInflight`
|
||||
* is scoped the same way, so a body still streaming counts toward the budget
|
||||
* of a capture starting beside it.
|
||||
*
|
||||
* Returns the PARSED envelope plus the response headers, because two callers
|
||||
* read `server-timing`, and `headersAt` because those same callers measure
|
||||
* header-vs-body time and can no longer observe that moment themselves.
|
||||
*
|
||||
* @param {string} url
|
||||
* @param {{full?: boolean}} [opts]
|
||||
* @returns {Promise<{json: unknown, headers: Headers|undefined, headersAt: number}>}
|
||||
*/
|
||||
async _fetchTerminalCapture(url, opts = {}) {
|
||||
const deadlineMs =
|
||||
typeof CodemanFetchDeadline !== 'undefined'
|
||||
? CodemanFetchDeadline.terminalFetchDeadlineMs({
|
||||
full: !!opts.full,
|
||||
inflight: this._terminalCaptureInflight || 0,
|
||||
})
|
||||
: 45000;
|
||||
// AbortSignal.timeout() is not on every browser Codeman supports, so drive
|
||||
// it from a controller and always clear the timer — an uncancelled one
|
||||
// would abort a LATER request that reused this controller's signal.
|
||||
//
|
||||
// Degrade to a plain fetch where AbortController is missing rather than
|
||||
// throwing: a capture with no deadline is the behaviour every caller had
|
||||
// before this helper existed, while a ReferenceError here would take out
|
||||
// terminal replay entirely. The deadline is a safety net, not a dependency.
|
||||
const canAbort = typeof AbortController === 'function';
|
||||
const controller = canAbort ? new AbortController() : null;
|
||||
const timer = controller ? setTimeout(() => controller.abort(), deadlineMs) : null;
|
||||
this._terminalCaptureInflight = (this._terminalCaptureInflight || 0) + 1;
|
||||
try {
|
||||
const res = await (controller ? fetch(url, { signal: controller.signal }) : fetch(url));
|
||||
const headersAt = performance.now();
|
||||
// Still inside the deadline: an abort here rejects the body stream, which
|
||||
// is exactly the case a header-only timeout could not reach.
|
||||
const json = await res.json();
|
||||
return { json, headers: res.headers, headersAt };
|
||||
} catch (err) {
|
||||
if (err?.name === 'AbortError') {
|
||||
_crashDiag.log(`TERMINAL FETCH TIMEOUT after ${deadlineMs}ms`);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
if (timer !== null) clearTimeout(timer);
|
||||
this._terminalCaptureInflight = Math.max(0, (this._terminalCaptureInflight || 1) - 1);
|
||||
}
|
||||
}
|
||||
|
||||
async _onSessionNeedsRefresh(event = {}) {
|
||||
// Server sends this after SSE backpressure clears — terminal data was dropped,
|
||||
// so reload the buffer to recover from any display corruption.
|
||||
@@ -2622,17 +2723,18 @@ class CodemanApp {
|
||||
// TUI modes still recover the whole picture, with the downgrade guard for
|
||||
// repaint-mode panes whose tmux capture can be smaller than xterm's buffer.
|
||||
const useFullHistory = this.sessions.get(sessionId)?.mode !== 'shell';
|
||||
let res = await fetch(
|
||||
let capture = await this._fetchTerminalCapture(
|
||||
useFullHistory
|
||||
? `/api/sessions/${sessionId}/terminal?full=1`
|
||||
: `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`
|
||||
: `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`,
|
||||
{ full: useFullHistory }
|
||||
);
|
||||
let headersReceivedAt = performance.now();
|
||||
let data = (await res.json())?.data ?? {};
|
||||
let headersReceivedAt = capture.headersAt;
|
||||
let data = capture.json?.data ?? {};
|
||||
if (useFullHistory && data.terminalBuffer && this._replayWouldShrinkBuffer(data.terminalBuffer)) {
|
||||
res = await fetch(`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`);
|
||||
headersReceivedAt = performance.now();
|
||||
data = (await res.json())?.data ?? {};
|
||||
capture = await this._fetchTerminalCapture(`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`);
|
||||
headersReceivedAt = capture.headersAt;
|
||||
data = capture.json?.data ?? {};
|
||||
}
|
||||
// Bail on a tab switch mid-fetch: writing here would paint this session's
|
||||
// history into the terminal the user is now looking at. The window is two
|
||||
@@ -2645,8 +2747,11 @@ class CodemanApp {
|
||||
// meaningless across it — distance from the bottom is what survives.
|
||||
const before = this.terminal.buffer?.active;
|
||||
const linesFromBottom = before ? Math.max(0, (before.baseY || 0) - (before.viewportY || 0)) : 0;
|
||||
this.terminal.clear();
|
||||
this.terminal.reset();
|
||||
// One queued clear, not clear()+reset(): both of those are synchronous
|
||||
// and skip xterm's write queue, so live bytes still parsing would land
|
||||
// after them and fuse into the buffer written below. See
|
||||
// _resetTerminalForReplay.
|
||||
this._resetTerminalForReplay();
|
||||
await this.chunkedTerminalWrite(
|
||||
data.terminalBuffer,
|
||||
TERMINAL_CHUNK_SIZE,
|
||||
@@ -2674,6 +2779,16 @@ class CodemanApp {
|
||||
this.sendResize(this.activeSessionId);
|
||||
}
|
||||
}
|
||||
// ⚠️ HERE: after a response arrived, and NOT in the `finally`. The marker
|
||||
// means "this session lost output", and only a reconcile that actually
|
||||
// completed settles it. Clearing on every exit meant one that threw — or
|
||||
// hit the fetch deadline, which is the flaky-link case the marker exists
|
||||
// for — dropped the gap silently with nothing to retry it.
|
||||
// ⚠️ Outside the `if (data.terminalBuffer)` too: a server that answers
|
||||
// with an empty capture HAS reconciled us, there was simply nothing to
|
||||
// replay. Leaving the marker set there refetched on every reconnect for
|
||||
// the life of the page.
|
||||
this._markTerminalBufferReconciled(sessionId);
|
||||
} catch (err) {
|
||||
console.error('needsRefresh reload failed:', err);
|
||||
} finally {
|
||||
@@ -2681,6 +2796,17 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the "this session lost output" marker.
|
||||
*
|
||||
* Called from every path that repaints a session's buffer from the server, so
|
||||
* the ws.onopen reconcile fires once and only when nothing else already did
|
||||
* the work. See the ws.onclose note for what the marker means.
|
||||
*/
|
||||
_markTerminalBufferReconciled(sessionId) {
|
||||
if (sessionId && this._wsOutputGapSession === sessionId) this._wsOutputGapSession = null;
|
||||
}
|
||||
|
||||
async _onSessionClearTerminal(data) {
|
||||
if (data.id === this.activeSessionId) {
|
||||
// Skip if selectSession is already loading the buffer — clearTerminal arriving
|
||||
@@ -2691,12 +2817,16 @@ class CodemanApp {
|
||||
|
||||
// Fetch buffer, clear terminal, write buffer, resize (no Ctrl+L needed)
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${data.id}/terminal`);
|
||||
const headersReceivedAt = performance.now();
|
||||
const termData = (await res.json())?.data ?? {};
|
||||
// No-param capture: `terminalBufferMaxBytes` (32MB) is its only ceiling,
|
||||
// so it needs the full-history budget. Defaulting to the tail budget
|
||||
// gave the largest payload the smallest deadline.
|
||||
const capture = await this._fetchTerminalCapture(`/api/sessions/${data.id}/terminal`, { full: true });
|
||||
const headersReceivedAt = capture.headersAt;
|
||||
const termData = capture.json?.data ?? {};
|
||||
|
||||
this.terminal.clear();
|
||||
this.terminal.reset();
|
||||
// Queued clear — see _resetTerminalForReplay for why clear()+reset()
|
||||
// cannot do this job.
|
||||
this._resetTerminalForReplay();
|
||||
if (termData.terminalBuffer) {
|
||||
// Strip any DEC 2026 markers and write raw content
|
||||
// (markers don't help here - this is a static buffer reload, not live Ink redraws)
|
||||
@@ -3021,6 +3151,22 @@ class CodemanApp {
|
||||
// Flush any durably-queued input over the fresh socket (covers frames a
|
||||
// prior half-open socket silently dropped, and input typed while offline).
|
||||
this._onWsReady(sessionId);
|
||||
// Reconcile the output hole this drop left (see the ws.onclose note).
|
||||
// Only after an unintentional close — a first connect has no gap, and
|
||||
// refetching there would duplicate the buffer selectSession just wrote.
|
||||
if (this._wsOutputGapSession === sessionId) {
|
||||
// NOT cleared here. `_onSessionNeedsRefresh` clears it once it has
|
||||
// actually repainted; a reconcile that fails or is skipped (a buffer
|
||||
// load already in flight, a tab switch) leaves the marker set so the
|
||||
// next open retries. Re-entry is safe: `_terminalRefreshOwner` makes
|
||||
// a second reconcile for the same session a no-op.
|
||||
_crashDiag.log(`WS REOPEN: reconciling output gap for ${sessionId}`);
|
||||
// Fire-and-forget: this is recovery, and a failure here must not stop
|
||||
// the socket coming up. _onSessionNeedsRefresh already guards against
|
||||
// running while a buffer load is in flight and against a tab switch
|
||||
// landing this session's history in another session's terminal.
|
||||
void this._onSessionNeedsRefresh({ id: sessionId });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3042,6 +3188,10 @@ class CodemanApp {
|
||||
// Input ACK — the server applied (or deduped) this seq; drop it from
|
||||
// the durable queue so it can never be re-delivered/lost.
|
||||
this._onWsInputAck(msg.seq, msg);
|
||||
} else if (msg.t === 'zc') {
|
||||
// Resize confirm — the geometry the PTY actually holds, which is not
|
||||
// always the one this client asked for (issue #464).
|
||||
this._onPtyGeometryReport(sessionId, msg.c, msg.r);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed messages
|
||||
@@ -3068,6 +3218,30 @@ class CodemanApp {
|
||||
`WS CLOSE code=${event.code} reason=${event.reason || ''} action=${plan.action} attempts=${this._wsReconnectAttempts || 0}`
|
||||
);
|
||||
|
||||
// Output frames carry no sequence number, so a dropped socket leaves a
|
||||
// hole with nothing to replay it. ws.onopen re-sends dims and flushes
|
||||
// queued INPUT; `needsRefresh` fires only on external-CLI startup and on
|
||||
// SSE backpressure drain, never here.
|
||||
//
|
||||
// ⚠️ The gap this closes is NARROWER than "the device went offline". If
|
||||
// the network drops, SSE drops with it and `handleInit`'s keepTerminal
|
||||
// branch already reconciles on reconnect. The uncovered case is the WS
|
||||
// dying while SSE stays up — a half-open socket, a proxy idle-timeout,
|
||||
// a ping timeout — because `_onSSETerminal` discards every SSE terminal
|
||||
// frame while `_wsReady` is true, and `_wsReady` only flips here, in
|
||||
// onclose. Detecting a half-open socket takes up to the ping+pong window,
|
||||
// and that whole span produces output nothing writes to the terminal.
|
||||
//
|
||||
// Reaching onclose at all means the drop was NOT intentional
|
||||
// (_disconnectWs nulls this handler first), so mark the gap and let the
|
||||
// next successful open reconcile from the server's buffer.
|
||||
//
|
||||
// Scoped to the session that actually lost bytes: a user who switches
|
||||
// sessions during an outage gets a clean intentional disconnect for the
|
||||
// new one, and its freshly-loaded buffer must not be refetched because a
|
||||
// DIFFERENT session's socket dropped.
|
||||
this._wsOutputGapSession = sessionId;
|
||||
|
||||
const stillActive = this.activeSessionId === sessionId;
|
||||
if (plan.action === 'give-up') {
|
||||
this._wsState = stillActive ? 'fallback' : 'disconnected';
|
||||
@@ -5946,9 +6120,29 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the terminal for a replay, IN STREAM.
|
||||
*
|
||||
* xterm's `write()` is asynchronously queued (the WriteBuffer parses in ~12ms
|
||||
* slices) while `Terminal.reset()` is synchronous and, by upstream's own
|
||||
* documentation, "does not clear input buffers and does not reset the parser,
|
||||
* thus the terminal will continue to apply pending input data". So bytes
|
||||
* queued just before a `reset()` are parsed AFTER it and fuse into whatever
|
||||
* snapshot is written next — measured upstream as `p8rmissions` rendered
|
||||
* where `bypass permissions` belonged.
|
||||
*
|
||||
* A queued clear cannot race that way: it lands after the leftovers and
|
||||
* before the snapshot, whatever the queue held. This function used to follow
|
||||
* the sync `reset()` with a queued `\x1b[3J\x1b[H\x1b[2J`, which already got
|
||||
* that right for CONTENT. RIS (`\x1bc`) additionally resets modes, charsets,
|
||||
* scroll regions and SGR state, so leftover bytes cannot park the terminal in
|
||||
* alt-screen or an odd scroll region and survive the clear.
|
||||
*
|
||||
* Callers may write the replacement content in as many chunks as they like —
|
||||
* ordering within the queue is what matters, not writing it all at once.
|
||||
*/
|
||||
_resetTerminalForReplay() {
|
||||
this.terminal.reset();
|
||||
this.terminal.write('\x1b[3J\x1b[H\x1b[2J');
|
||||
this.terminal.write('\x1bc');
|
||||
}
|
||||
|
||||
_recordTerminalLoadTiming(timing) {
|
||||
@@ -6012,9 +6206,9 @@ class CodemanApp {
|
||||
this._fullHistoryRepullInFlight = true;
|
||||
try {
|
||||
const requestStartedAt = performance.now();
|
||||
const res = await fetch(`/api/sessions/${sessionId}/terminal?full=1`);
|
||||
const headersReceivedAt = performance.now();
|
||||
const payload = (await res.json())?.data ?? {};
|
||||
const capture = await this._fetchTerminalCapture(`/api/sessions/${sessionId}/terminal?full=1`, { full: true });
|
||||
const headersReceivedAt = capture.headersAt;
|
||||
const payload = capture.json?.data ?? {};
|
||||
const bodyParsedAt = performance.now();
|
||||
const buffer = payload.terminalBuffer;
|
||||
const timing = {
|
||||
@@ -6027,7 +6221,7 @@ class CodemanApp {
|
||||
bodyAndJsonMs: bodyParsedAt - headersReceivedAt,
|
||||
resetAndParseMs: 0,
|
||||
totalMs: 0,
|
||||
serverTiming: res.headers?.get?.('server-timing') || '',
|
||||
serverTiming: capture.headers?.get?.('server-timing') || '',
|
||||
refused: false,
|
||||
};
|
||||
// Bail on a tab switch mid-fetch: writing here would paint another session's
|
||||
@@ -6395,11 +6589,14 @@ class CodemanApp {
|
||||
// For that just-created-session case we flush (not discard) queued SSE events.
|
||||
let bufferWasEmpty = false;
|
||||
let cacheResetAndParseMs = 0;
|
||||
// Hoisted out of the try: the catch needs to know whether the pane was
|
||||
// blanked before the fetch, because only then is there nothing on screen.
|
||||
let clearedBeforeFresh = false;
|
||||
try {
|
||||
// Fit terminal to container BEFORE writing any buffer data.
|
||||
// If the browser was resized while viewing another session, the terminal
|
||||
// canvas may be at stale dimensions — content would render at wrong width.
|
||||
if (this.fitAddon) this.fitAddon.fit();
|
||||
this.syncTerminalGeometry();
|
||||
|
||||
// Also push the new dimensions to the PTY. Without this, codex/codeman
|
||||
// sees the size that was set the last time the throttled resize handler
|
||||
@@ -6476,7 +6673,8 @@ class CodemanApp {
|
||||
// blank and rewrites with fresh data. Skip the cache and write the fresh
|
||||
// buffer once for a single clean transition.
|
||||
const cachedBuffer = this.terminalBufferCache.get(sessionId);
|
||||
let clearedBeforeFresh = false;
|
||||
// `clearedBeforeFresh` is declared above the try, because the catch reads
|
||||
// it — re-declaring it here would shadow that and silently break it.
|
||||
if (cachedBuffer && !sessionIsBusy && !restoredSnapshot && session?.mode !== 'shell') {
|
||||
_crashDiag.log(`CACHE_WRITE: ${(cachedBuffer.length/1024).toFixed(0)}KB`);
|
||||
this._setTerminalLoadState(sessionId, selectGen, 'replaying');
|
||||
@@ -6525,17 +6723,32 @@ class CodemanApp {
|
||||
const useFullHistory = session?.mode !== 'shell' && !this._fullHistoryLoaded.has(sessionId);
|
||||
if (useFullHistory) this._fullHistoryLoaded.add(sessionId);
|
||||
const fetchStartedAt = performance.now();
|
||||
const res = await fetch(
|
||||
useFullHistory
|
||||
? `/api/sessions/${sessionId}/terminal?full=1`
|
||||
: `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`
|
||||
);
|
||||
const headersReceivedAt = performance.now();
|
||||
const tailUrl = `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`;
|
||||
let capture;
|
||||
try {
|
||||
capture = await this._fetchTerminalCapture(
|
||||
useFullHistory ? `/api/sessions/${sessionId}/terminal?full=1` : tailUrl,
|
||||
{ full: useFullHistory }
|
||||
);
|
||||
} catch (err) {
|
||||
// The deadline made a slow link reachable for the first time, and the
|
||||
// pane was already blanked above — so an abort here used to leave a
|
||||
// black rectangle, discard the queued live output, and never reach
|
||||
// `_connectWs`. Degrade to the bounded tail instead: less history, but
|
||||
// a working tab. Only for the full-history pull; the tail has nothing
|
||||
// smaller to fall back to, and a second failure is the honest floor.
|
||||
if (err?.name !== 'AbortError' || !useFullHistory) throw err;
|
||||
_crashDiag.log('FULL CAPTURE ABORTED → tail');
|
||||
// It never loaded, so the next select must be allowed to try again.
|
||||
this._fullHistoryLoaded.delete(sessionId);
|
||||
capture = await this._fetchTerminalCapture(tailUrl);
|
||||
}
|
||||
const headersReceivedAt = capture.headersAt;
|
||||
if (this._isStaleSelect(selectGen)) {
|
||||
this._clearTerminalLoadState(sessionId, selectGen);
|
||||
return;
|
||||
}
|
||||
const data = (await res.json())?.data ?? {};
|
||||
const data = capture.json?.data ?? {};
|
||||
const bodyParsedAt = performance.now();
|
||||
// How this load must end, decided here because `chunkedTerminalWrite` is
|
||||
// what actually ends it for a non-empty buffer. A tmux pane capture is a
|
||||
@@ -6615,7 +6828,7 @@ class CodemanApp {
|
||||
cacheResetAndParseMs,
|
||||
freshResetAndParseMs,
|
||||
selectToReplayCompleteMs: performance.now() - _selStart,
|
||||
serverTiming: res.headers?.get?.('server-timing') || '',
|
||||
serverTiming: capture.headers?.get?.('server-timing') || '',
|
||||
};
|
||||
// Buffer load complete — unblock live SSE writes. chunkedTerminalWrite calls
|
||||
// _finishBufferLoad after ordering the fetched snapshot in xterm; if we skipped
|
||||
@@ -6632,6 +6845,11 @@ class CodemanApp {
|
||||
bufferWasEmpty ? { flushQueued: true, since: 0 } : finishOpts
|
||||
);
|
||||
}
|
||||
// This load repainted the session from the server, so any pending
|
||||
// output-gap marker is already satisfied. Selecting a session runs BEFORE
|
||||
// _connectWs, so without this the socket opening afterwards would replay
|
||||
// the whole buffer again on top of the one just written.
|
||||
this._markTerminalBufferReconciled(sessionId);
|
||||
// Drop the guard so user input clears state normally
|
||||
this._restoringFlushedState = false;
|
||||
|
||||
@@ -6726,17 +6944,28 @@ class CodemanApp {
|
||||
// and, because it goes through `forceReload`, a dropped and reopened
|
||||
// WebSocket plus a deleted xterm snapshot.
|
||||
//
|
||||
// That equality is the signature of a CLAMP rather than a race.
|
||||
// `getTerminalDimensions()` floors at 40x10 while `fitAddon.fit()` does
|
||||
// not, so a terminal narrower than 40 columns or shorter than 10 rows
|
||||
// reports a pane permanently bigger than itself, and every select would
|
||||
// retry without ever converging. A race never produces this equality: its
|
||||
// whole premise is that the pane was still at the size we asked it to
|
||||
// leave. The other non-converging case, `Session.resize` declining a
|
||||
// small viewport while a desktop claim is live, does not produce it
|
||||
// either — that pane sits at the DESKTOP's size — so it still costs the
|
||||
// one capped attempt, and stopping it needs the pane-ownership policy
|
||||
// this does not touch.
|
||||
// A race never produces this equality: its whole premise is that the pane
|
||||
// was still at the size we asked it to leave. So the equality means the
|
||||
// pane already IS what we asked for and a retry would capture the same
|
||||
// frame twice.
|
||||
//
|
||||
// ⚠️ This used to also be the signature of a CLAMP, and that is now fixed
|
||||
// at the source rather than worked around here (issue #464).
|
||||
// `getTerminalDimensions()` floors at 40x10 while `fitAddon.fit()` did
|
||||
// not, so a terminal under 40 columns or 10 rows reported a pane
|
||||
// permanently bigger than itself and every select retried without ever
|
||||
// converging. `syncTerminalGeometry()` now applies that floor to xterm as
|
||||
// well, so the browser terminal IS the size it reports and the clamp can
|
||||
// no longer manufacture a mismatch — which also means the repair below is
|
||||
// reached only by cases it can actually repair.
|
||||
//
|
||||
// The other non-converging case, `Session.resize` declining a small
|
||||
// viewport while a desktop claim is live, does not produce this equality
|
||||
// either — that pane sits at the DESKTOP's size. It no longer needs
|
||||
// repairing from here: the server reports the geometry the PTY actually
|
||||
// holds ({"t":"zc"} / the resize response) and `_onPtyGeometryReport`
|
||||
// adopts it, so the terminal matches the pane that is being drawn instead
|
||||
// of replaying against one that never existed.
|
||||
const captureMatchesRequestedSize =
|
||||
!!dimsAfterLoad && data.captureCols === dimsAfterLoad.cols && data.captureRows === dimsAfterLoad.rows;
|
||||
|
||||
@@ -6903,14 +7132,46 @@ class CodemanApp {
|
||||
} catch (err) {
|
||||
if (this._isLoadingBuffer) this._finishBufferLoad(bufferLoadOwner);
|
||||
this._restoringFlushedState = false;
|
||||
this._setTerminalLoadState(sessionId, selectGen, 'failed');
|
||||
console.error('Failed to load session terminal:', err);
|
||||
if (this._isStaleSelect(selectGen)) {
|
||||
this._clearTerminalLoadState(sessionId, selectGen);
|
||||
return;
|
||||
}
|
||||
// The history did not load. That is not a reason to leave the tab dead:
|
||||
// ⚠️ the socket is what carries LIVE output, and it is opened at the end
|
||||
// of the happy path, so bailing here left the session mute until the user
|
||||
// switched away and back.
|
||||
this._connectWs(sessionId);
|
||||
// Only when the pane was blanked for a replay that never came. A pane
|
||||
// still holding its previous content is stale, not empty, and stacking a
|
||||
// notice on top of readable output is worse than the staleness.
|
||||
if (clearedBeforeFresh && this.terminal) {
|
||||
// Three short lines, none over 25 columns, because the narrowest
|
||||
// terminal this app will render is the 40-column floor and a notice
|
||||
// that wraps there leaves a lone '.' on a line of its own — measured at
|
||||
// 320px, where a single 52-character sentence did exactly that.
|
||||
// Each line is one fact: what failed, that the session is still alive,
|
||||
// and what to do. The last says RELOAD rather than "reopen the tab",
|
||||
// because `selectSession` early-returns when the session is already
|
||||
// active, so clicking the tab you are already on retries nothing.
|
||||
this.terminal.write(
|
||||
'\r\n\x1b[2m History did not load.\r\n Live output continues.\r\n Reload to try again.\x1b[0m\r\n'
|
||||
);
|
||||
}
|
||||
// ⚠️ CLEAR, not 'failed'. `_setTerminalLoadState` only marks the TAB, and
|
||||
// nothing ever cleared it on this path — so the tab kept its spinner and
|
||||
// `aria-busy="true"` forever, telling every reader and every screen reader
|
||||
// that a load was still running when it had already given up.
|
||||
this._clearTerminalLoadState(sessionId, selectGen);
|
||||
}
|
||||
}
|
||||
|
||||
// Shared cleanup for all session data — called from both closeSession() and session:deleted handler
|
||||
_cleanupSessionData(sessionId) {
|
||||
this.closeTabRailActionMenu?.();
|
||||
// A dead session has no buffer to reconcile; leaving the marker set would
|
||||
// make a later socket for a REUSED id reconcile against nothing.
|
||||
this._markTerminalBufferReconciled(sessionId);
|
||||
// If the deleted session is currently being renamed, abort the rename
|
||||
// so the inline <input> doesn't ghost as a stale tab on screen.
|
||||
if (this._activeRename?.sessionId === sessionId) {
|
||||
|
||||
@@ -1585,6 +1585,187 @@ function buildSplitPickerSessions(sessions, sessionOrder, excludeId, detachedIds
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Renderer liveness ──────────────────────────────────────────────────────
|
||||
//
|
||||
// iOS DISCARDS scheduled requestAnimationFrame callbacks when a PWA goes to
|
||||
// the background — not deferred, never delivered. xterm's RenderDebouncer only
|
||||
// clears its `_animationFrame` handle from INSIDE that callback:
|
||||
//
|
||||
// refresh() {
|
||||
// if (this._animationFrame !== undefined) return; // <- stale forever
|
||||
// this._animationFrame = requestAnimationFrame(() => this._innerRefresh());
|
||||
// }
|
||||
// _innerRefresh() { this._animationFrame = undefined; ... } // never runs
|
||||
//
|
||||
// So after one backgrounding the handle is permanently non-undefined and EVERY
|
||||
// later render request returns on line one. Parsing is decoupled from
|
||||
// rendering, so bytes keep filling the buffer correctly and nothing throws —
|
||||
// the terminal is simply frozen. Closing and reopening fixes it because that
|
||||
// constructs a new Terminal, and therefore a new debouncer.
|
||||
//
|
||||
// Codeman is MORE exposed than a per-session-terminal app: there is exactly one
|
||||
// xterm instance for the whole page load, so a single backgrounding can wedge
|
||||
// it until a full reload.
|
||||
//
|
||||
// This is the pure decision half. The signature that distinguishes this from
|
||||
// every other way a terminal can look stuck is that bytes were WRITTEN and the
|
||||
// element is VISIBLE, yet onRender has not fired since:
|
||||
//
|
||||
// frozen = wroteAt > renderedAt && now - wroteAt >= threshold && visible
|
||||
//
|
||||
// Deliberately NOT a "no output at all" check: a quiet terminal is the normal
|
||||
// state and must never be kicked. And `visible` is required because a hidden
|
||||
// terminal legitimately stops rendering (xterm pauses it), so kicking there
|
||||
// would fire constantly on every backgrounded tab.
|
||||
const RENDER_STALL_MS = 4000;
|
||||
|
||||
// How often the watchdog checks. Deliberately coarse: the failure it catches is
|
||||
// permanent until healed, so detecting it a second late costs nothing, while a
|
||||
// tight interval would burn a wakeup per second on every idle phone.
|
||||
const RENDER_LIVENESS_POLL_MS = 2000;
|
||||
|
||||
/**
|
||||
* Should the renderer be kicked? Pure so the CI gate can cover it — the DOM
|
||||
* half (cancelling the stale handle) lives in terminal-ui.js.
|
||||
*
|
||||
* @param {{wroteAt:number, renderedAt:number, now:number, visible:boolean,
|
||||
* thresholdMs?:number}} s
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function shouldKickRenderer(s) {
|
||||
if (!s || !s.visible) return false;
|
||||
const wroteAt = Number(s.wroteAt) || 0;
|
||||
const renderedAt = Number(s.renderedAt) || 0;
|
||||
const now = Number(s.now) || 0;
|
||||
// Nothing written yet — a fresh terminal has no render to be missing.
|
||||
if (wroteAt <= 0) return false;
|
||||
// A render landed at or after the last write: the pipeline is alive.
|
||||
if (renderedAt >= wroteAt) return false;
|
||||
const threshold = Number.isFinite(s.thresholdMs) && s.thresholdMs > 0 ? s.thresholdMs : RENDER_STALL_MS;
|
||||
return now - wroteAt >= threshold;
|
||||
}
|
||||
|
||||
// ── Fetch deadlines ────────────────────────────────────────────────────────
|
||||
//
|
||||
// No terminal fetch carried any deadline, including `?full=1`, which the code
|
||||
// itself describes as "unbounded-ish work: at the default history limit it can
|
||||
// be megabytes". On a stalled mobile link that request hangs on the browser
|
||||
// default with no retry and no path back to a usable terminal short of a
|
||||
// reload.
|
||||
//
|
||||
// A single fixed timeout is wrong in both directions — too short for a full
|
||||
// scrollback capture on a slow uplink, too long for a small tail on a dead
|
||||
// connection. So the deadline is scaled by what is actually being asked for,
|
||||
// and by how many captures are already in flight: on a slow link those bytes
|
||||
// must drain before this request's own bytes start moving, and its timer is
|
||||
// already running the whole time.
|
||||
const FETCH_DEADLINE_TAIL_MS = 15000;
|
||||
const FETCH_DEADLINE_FULL_MS = 45000;
|
||||
const FETCH_DEADLINE_MAX_MS = 120000;
|
||||
|
||||
/**
|
||||
* Deadline in ms for a terminal capture.
|
||||
*
|
||||
* @param {{full?:boolean, inflight?:number}} s - `full` = the ?full=1 capture;
|
||||
* `inflight` = captures already running (this one included or not, it only
|
||||
* scales the budget).
|
||||
* @returns {number}
|
||||
*/
|
||||
function terminalFetchDeadlineMs(s) {
|
||||
const full = !!(s && s.full);
|
||||
const base = full ? FETCH_DEADLINE_FULL_MS : FETCH_DEADLINE_TAIL_MS;
|
||||
const inflight = Math.max(0, Number(s && s.inflight) || 0);
|
||||
// Each already-queued capture gets the newcomer one more base budget to wait
|
||||
// through. Linear rather than clever: the point is only that eight tabs
|
||||
// resuming do not all time out together because each assumed it was alone.
|
||||
return Math.min(FETCH_DEADLINE_MAX_MS, base * (1 + inflight));
|
||||
}
|
||||
|
||||
// ── Diagnostics hygiene ────────────────────────────────────────────────────
|
||||
//
|
||||
// The crash trail is joined with '\n' into ONE localStorage value and beaconed
|
||||
// to the server, and at least one call site interpolates server-controlled text
|
||||
// (a WebSocket close `reason`). An embedded newline there forges extra entries
|
||||
// in the trail; an unbounded string can fill the storage quota. Both are cheap
|
||||
// to close, and the trail is something a user may be asked to paste into an
|
||||
// issue.
|
||||
const DIAG_ENTRY_MAX_CHARS = 300;
|
||||
|
||||
/** Flatten a diagnostic message to one bounded, newline-free line. */
|
||||
function sanitizeDiagEntry(msg) {
|
||||
return String(msg == null ? '' : msg)
|
||||
.replace(/[\r\n\u2028\u2029]+/g, ' ')
|
||||
.slice(0, DIAG_ENTRY_MAX_CHARS);
|
||||
}
|
||||
|
||||
// ── Terminal geometry: xterm and the PTY must never disagree ───────────────
|
||||
//
|
||||
// Issue #464 ("text gets muffled"). Claude Code's TUI repaints by wrapping its
|
||||
// frame at the width the PTY reported and walking the cursor up that many
|
||||
// ROWS. So a browser terminal whose width differs from the PTY's makes every
|
||||
// repaint arithmetic wrong: a logical line occupies more physical rows than
|
||||
// Ink counted, `eraseLines(n)` clears too few of them, and the new frame paints
|
||||
// over rows that were never erased. Measured against a real xterm — a PTY
|
||||
// believing 120 columns against a 62-column terminal renders each wrapped line
|
||||
// twice, and a shorter replacement line leaves the tail of the old one behind.
|
||||
// That is exactly the doubled rows and half-overwritten prose in the report.
|
||||
//
|
||||
// The floor exists because a PTY a handful of columns wide makes any CLI wrap
|
||||
// every word; it is NOT a display preference, so the browser terminal has to
|
||||
// honour it too. Three separate call sites used to fit xterm to the RAW
|
||||
// proposal and report the CLAMPED one, which is how the two drifted apart with
|
||||
// nothing to notice: resize is write-only, so nobody could see the disagreement.
|
||||
const TERMINAL_MIN_COLS = 40;
|
||||
const TERMINAL_MIN_ROWS = 10;
|
||||
|
||||
/**
|
||||
* The geometry to apply AND report — there is only ever one answer to both.
|
||||
* @param {{cols: number, rows: number}|null|undefined} proposed
|
||||
* @returns {{cols: number, rows: number}|null}
|
||||
*/
|
||||
function clampTerminalDimensions(proposed) {
|
||||
if (!proposed || !Number.isFinite(proposed.cols) || !Number.isFinite(proposed.rows)) return null;
|
||||
return {
|
||||
cols: Math.max(Math.trunc(proposed.cols), TERMINAL_MIN_COLS),
|
||||
rows: Math.max(Math.trunc(proposed.rows), TERMINAL_MIN_ROWS),
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether two geometries are the same screen. Either being absent is a mismatch. */
|
||||
function terminalGeometryAgrees(a, b) {
|
||||
return !!a && !!b && a.cols === b.cols && a.rows === b.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* What to do when the server reports the PTY's real geometry.
|
||||
*
|
||||
* The server is the authority: it owns the PTY the CLI is drawing for, and it
|
||||
* can refuse a resize outright (`Session.resize` ignores small-viewport
|
||||
* requests while a desktop connection holds an active sizing claim) without
|
||||
* the asking client ever being told. A terminal that keeps its own WIDTH after
|
||||
* such a refusal renders garbage, because Ink wraps its frame and counts its
|
||||
* erase rows at the width it was told.
|
||||
*
|
||||
* ⚠️ COLUMNS ONLY. Rows are deliberately left alone, and adopting them was a
|
||||
* real regression: a phone that took a desktop's 43 rows into a viewport with
|
||||
* room for 18 painted an `.xterm-screen` far taller than its container, and
|
||||
* because xterm's own viewport then had nothing to scroll, the bottom of the
|
||||
* frame — the CLI's input line — sat below the container with no gesture that
|
||||
* could reach it. Output visible, typing invisible, for as long as the claim
|
||||
* stayed hot. Width is the axis the wrap arithmetic depends on; rows only
|
||||
* decide how much is on screen at once, and keeping the local row count keeps
|
||||
* the composer at the bottom of a viewport that scrolls.
|
||||
*
|
||||
* @param {{cols: number, rows: number}|null} local - what xterm currently holds
|
||||
* @param {{cols: number, rows: number}|null} pty - what the server just reported
|
||||
* @returns {{adopt: boolean, cols: number|null}}
|
||||
*/
|
||||
function reconcilePtyGeometry(local, pty) {
|
||||
if (!pty || !Number.isFinite(pty.cols)) return { adopt: false, cols: null };
|
||||
if (!local || !Number.isFinite(local.cols) || local.cols === pty.cols) return { adopt: false, cols: null };
|
||||
return { adopt: true, cols: pty.cols };
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice, computeRewriteScrollLine };
|
||||
window.CodemanFilePaths = { absoluteFilePathPattern, previewsInFileViewer, FILE_PREVIEW_EXTENSIONS };
|
||||
@@ -1594,4 +1775,19 @@ if (typeof window !== 'undefined') {
|
||||
buildSplitPickerSessions,
|
||||
SPLIT_PANE_MIN_WIDTH,
|
||||
};
|
||||
window.CodemanRenderLiveness = { shouldKickRenderer, RENDER_STALL_MS, RENDER_LIVENESS_POLL_MS };
|
||||
window.CodemanFetchDeadline = {
|
||||
terminalFetchDeadlineMs,
|
||||
FETCH_DEADLINE_TAIL_MS,
|
||||
FETCH_DEADLINE_FULL_MS,
|
||||
FETCH_DEADLINE_MAX_MS,
|
||||
};
|
||||
window.CodemanDiag = { sanitizeDiagEntry, DIAG_ENTRY_MAX_CHARS };
|
||||
window.CodemanTerminalGeometry = {
|
||||
clampTerminalDimensions,
|
||||
terminalGeometryAgrees,
|
||||
reconcilePtyGeometry,
|
||||
TERMINAL_MIN_COLS,
|
||||
TERMINAL_MIN_ROWS,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -581,11 +581,10 @@ const KeyboardHandler = {
|
||||
this._settleRestoreScroll = false;
|
||||
|
||||
if (typeof app !== 'undefined' && app.terminal) {
|
||||
if (app.fitAddon) {
|
||||
try {
|
||||
app.fitAddon.fit();
|
||||
} catch {}
|
||||
}
|
||||
// Floored fit, not a bare fitAddon.fit(): _shrinkPaddingToFit measures
|
||||
// the leftover gap under the LAST row, so it has to run against the
|
||||
// geometry xterm will actually keep (issue #464).
|
||||
app.syncTerminalGeometry?.();
|
||||
if (this.keyboardVisible) this._shrinkPaddingToFit();
|
||||
// Following live output → bottom, as before. Reading history → back to
|
||||
// the pre-reflow anchor instead of being yanked down (#259).
|
||||
@@ -601,25 +600,23 @@ const KeyboardHandler = {
|
||||
}, this.VIEWPORT_SETTLE_MS);
|
||||
},
|
||||
|
||||
/** Send current terminal dimensions to the server (one-shot, for keyboard open/close) */
|
||||
/**
|
||||
* Send the settled terminal dimensions to the server (one-shot, for keyboard
|
||||
* open/close — `throttledResize` deliberately holds the PTY's shape for the
|
||||
* whole animation, so this is what stops it going stale).
|
||||
*
|
||||
* ⚠️ Delegates rather than computing its own numbers. This used to re-read
|
||||
* `proposeDimensions()` and floor only what it POSTed, so on a phone with the
|
||||
* keyboard up — where the proposal is routinely under ten rows — the PTY was
|
||||
* told ten and xterm kept six, which is the #464 divergence. Worse, it read
|
||||
* the proposal AFTER `_shrinkPaddingToFit()` had moved the container, so even
|
||||
* unfloored its answer could differ from the fit above it. `sendResize` fits,
|
||||
* floors and applies in one step, and additionally gets the WS fast path and
|
||||
* the detached-session yield this hand-rolled POST never had.
|
||||
*/
|
||||
_sendTerminalResize() {
|
||||
if (typeof app === 'undefined' || !app.activeSessionId || !app.fitAddon) return;
|
||||
try {
|
||||
const dims = app.fitAddon.proposeDimensions();
|
||||
if (dims) {
|
||||
const cols = Math.max(dims.cols, 40);
|
||||
const rows = Math.max(dims.rows, 10);
|
||||
app._lastResizeDims = { cols, rows };
|
||||
// Declare the viewport type so resize arbitration can ignore this
|
||||
// while a desktop connection is sizing the same session.
|
||||
const viewportType = MobileDetection.getDeviceType ? MobileDetection.getDeviceType() : 'mobile';
|
||||
fetch(`/api/sessions/${app.activeSessionId}/resize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cols, rows, viewportType }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
} catch {}
|
||||
if (typeof app === 'undefined' || !app.activeSessionId) return;
|
||||
app.sendResize?.(app.activeSessionId)?.catch?.(() => {});
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -676,10 +673,8 @@ const KeyboardHandler = {
|
||||
const currentPadding = parseInt(main.style.paddingBottom) || 0;
|
||||
const floor = Math.min(currentPadding, this._fixedBottomBarsHeight());
|
||||
main.style.paddingBottom = Math.max(floor, currentPadding - gap) + 'px';
|
||||
if (app.fitAddon)
|
||||
try {
|
||||
app.fitAddon.fit();
|
||||
} catch {}
|
||||
// Floored, like every other fit of the main terminal (#464).
|
||||
app.syncTerminalGeometry?.();
|
||||
}
|
||||
} catch {}
|
||||
},
|
||||
|
||||
@@ -512,8 +512,10 @@ class NotificationManager {
|
||||
}
|
||||
// Re-fit terminal and send resize to PTY so this client's dimensions win.
|
||||
// Fixes broken layout when switching between desktop and mobile on the same session.
|
||||
if (this.app?.fitAddon && this.app?.activeSessionId) {
|
||||
this.app.fitAddon.fit();
|
||||
// sendResize fits (floored) as its first synchronous step, so the bare
|
||||
// fit that used to precede it was both redundant and a chance to leave
|
||||
// xterm at the unfloored proposal (#464).
|
||||
if (this.app?.activeSessionId) {
|
||||
this.app.sendResize(this.app.activeSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5276,8 +5276,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
try { localStorage.removeItem('codeman-active-session'); } catch {}
|
||||
this.renderSessionTabs();
|
||||
this.renderMuxSessions();
|
||||
this.terminal.clear();
|
||||
this.terminal.reset();
|
||||
// Not a replay path, so the ordering hazard does not apply here — but
|
||||
// there is one way to clear this terminal and this is it, so a future
|
||||
// caller cannot copy a clear()+reset() pair out of here into one.
|
||||
this._resetTerminalForReplay();
|
||||
this.toast('All sessions and tmux killed', 'success');
|
||||
}
|
||||
} else {
|
||||
@@ -5286,8 +5288,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.activeSessionId = null;
|
||||
try { localStorage.removeItem('codeman-active-session'); } catch {}
|
||||
this.renderSessionTabs();
|
||||
this.terminal.clear();
|
||||
this.terminal.reset();
|
||||
this._resetTerminalForReplay();
|
||||
this.toast('All tabs removed, tmux still running', 'info');
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -155,10 +155,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (xtermViewport && scrollTop !== undefined) {
|
||||
xtermViewport.scrollTop = scrollTop;
|
||||
}
|
||||
// Refit terminal to new container size
|
||||
if (this.terminal && this.fitAddon) {
|
||||
this.fitAddon.fit();
|
||||
}
|
||||
// Refit terminal to new container size. Through the one owner so the
|
||||
// floor that is reported to the PTY is also the one xterm holds (#464).
|
||||
this.syncTerminalGeometry?.();
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -3112,7 +3112,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const changed = orientationChanged || previousDetail !== detail || previousSort !== sort;
|
||||
if (orientationChanged) {
|
||||
this.updateTabOverflowMode?.();
|
||||
if (!settleRailWidth) this.fitAddon?.fit();
|
||||
if (!settleRailWidth) this.syncTerminalGeometry?.();
|
||||
}
|
||||
// applyTabWrapSettings() is the ONE owner of tabs-show-folder and is
|
||||
// rail-aware, so it has to run AFTER the two attributes above — the
|
||||
|
||||
@@ -3862,6 +3862,37 @@ body.solo-mode .btn-lifecycle-log {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* The terminal is wider than the box that shows it (issue #464). Two causes,
|
||||
one affordance: another device holds the sizing claim so this terminal has
|
||||
adopted a width it did not ask for, or the 40-column floor has widened it
|
||||
past a narrow container. Either way the text is rendered CORRECTLY and simply
|
||||
does not fit, and without horizontal reach the right-hand columns sit behind
|
||||
.terminal-container's clip with no gesture that can get to them — measured at
|
||||
360px, font 24: 218px of the pane, 38% of it, unreachable.
|
||||
Present only while that is true; _syncTerminalOverflowAffordance() owns it. */
|
||||
.terminal-container.term-overflows-x {
|
||||
/* ⚠️ BOTH axes, explicitly. mobile.css loads after this file and sets
|
||||
`.terminal-container { overflow: visible }` — a bare `overflow-x` would
|
||||
then leave overflow-y computing to `auto` (CSS promotes a `visible` paired
|
||||
with a non-visible axis), handing the browser a vertical scroll container
|
||||
the terminal's own touch handler does not know about. */
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
/* xterm's own element is width:100% above, so the container would see no
|
||||
overflow to scroll even though .xterm-screen is wider than both. */
|
||||
.terminal-container.term-overflows-x .xterm {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
}
|
||||
/* ⚠️ NO `touch-action: pan-x` here, deliberately. The terminal's own touchmove
|
||||
handler pans this container (see `canPanHorizontally` in terminal-ui.js),
|
||||
because `touchstart` preventDefault()s every 'content' tap and that cancels
|
||||
a native pan before it can start. Granting the browser pan-x as well would
|
||||
double-handle the gestures where that preventDefault does NOT run — a tap on
|
||||
a scrolled-up viewport — moving the pane twice for one finger. The
|
||||
`touch-action: none` the other rules set is what keeps JS the sole owner. */
|
||||
|
||||
/* Touch devices: prevent browser from claiming the touch gesture before
|
||||
our JS touchmove handler fires. Without this, the browser starts native
|
||||
scrolling during the first few px of finger travel and ignores our
|
||||
|
||||
+36
-17
@@ -18,7 +18,16 @@
|
||||
* @see src/push-store.ts -- server-side VAPID key management and subscription CRUD
|
||||
*/
|
||||
|
||||
const CACHE_NAME = 'codeman-v1';
|
||||
// Build identity. scripts/build.mjs rewrites this declaration after it content-
|
||||
// hashes the assets; the literal below is what dev serves, and dev wants a
|
||||
// stable key.
|
||||
//
|
||||
// Why the cache key MUST carry it: `activate` deletes every cache whose key is
|
||||
// not the current one, so the old constant key meant that cleanup never deleted
|
||||
// anything — hashed assets from every release ever deployed accumulated in one
|
||||
// bucket until the origin hit its storage quota.
|
||||
const BUILD_ID = 'dev';
|
||||
const CACHE_NAME = `codeman-${BUILD_ID}`;
|
||||
|
||||
// Reverse-proxy base path: the worker is served at `<base>/sw.js`, so its own
|
||||
// location tells us the mount prefix ('' at root, or '/codeman'). Every URL below
|
||||
@@ -27,27 +36,27 @@ const CACHE_NAME = 'codeman-v1';
|
||||
const SW_BASE = self.location.pathname.replace(/\/sw\.js$/, '');
|
||||
const B = (p) => (p && p[0] === '/' ? SW_BASE + p : p);
|
||||
|
||||
// Content-hashed assets. scripts/build.mjs rewrites this declaration with the
|
||||
// filenames it actually emitted; dev has no hashing, so the empty literal below
|
||||
// is correct there and the unhashed modules are simply cached on first use by
|
||||
// the runtime handler further down.
|
||||
//
|
||||
// This list used to be maintained by hand with the PRE-hash names, which the
|
||||
// build then renamed — so in production every entry 404'd and the silent
|
||||
// `.catch()` in install swallowed all of it. Measured against a running
|
||||
// instance: 15 of 23 entries failed. Offline still worked, because the fetch
|
||||
// handler caches every successful GET at runtime, but the precache warmed
|
||||
// nothing while looking like it did. Deriving it from the same manifest that
|
||||
// renames the files is the only thing that keeps the two from drifting again.
|
||||
const HASHED_ASSETS = [];
|
||||
|
||||
// Core app shell -- cached on install for instant startup
|
||||
const APP_SHELL = [
|
||||
'/',
|
||||
'/styles.css',
|
||||
'/mobile.css',
|
||||
'/constants.js',
|
||||
'/app.js',
|
||||
'/api-client.js',
|
||||
'/terminal-ui.js',
|
||||
'/session-ui.js',
|
||||
'/settings-ui.js',
|
||||
'/panels-ui.js',
|
||||
'/notification-manager.js',
|
||||
'/mobile-handlers.js',
|
||||
'/keyboard-accessory.js',
|
||||
'/voice-input.js',
|
||||
...HASHED_ASSETS.map((p) => '/' + p),
|
||||
'/vendor/xterm.min.js',
|
||||
'/vendor/xterm-addon-fit.min.js',
|
||||
'/vendor/xterm-addon-unicode11.min.js',
|
||||
'/vendor/xterm-zerolag-input.js',
|
||||
'/vendor/xterm-predictive-echo.js',
|
||||
'/vendor/xterm.css',
|
||||
'/icon-192.png',
|
||||
'/icon-512.png',
|
||||
@@ -102,7 +111,17 @@ self.addEventListener('fetch', (event) => {
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(() => caches.match(request))
|
||||
// ignoreSearch, or the precache can never be hit. `renderIndexHtml` runs
|
||||
// `cacheBustAssets`, which appends `?v=<mtime>` to EVERY same-origin
|
||||
// `.js`/`.css` reference — content-hashed names included, so the page asks
|
||||
// for `/app.556be563.js?v=1789423735875` while the precache stored
|
||||
// `/app.556be563.js`. `caches.match` is query-sensitive by default, so
|
||||
// every precached entry was unreachable and only `/`, the icons and the
|
||||
// manifest could ever be served offline.
|
||||
//
|
||||
// It also makes runtime-cached entries survive an mtime change: the same
|
||||
// file re-requested under a new `?v=` still matches the copy already held.
|
||||
.catch(() => caches.match(request, { ignoreSearch: true }))
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -154,7 +154,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._persistTabRailWidth(preferred);
|
||||
try {
|
||||
if (this.activeSessionId && this.sendResize) await this.sendResize(this.activeSessionId);
|
||||
else this.fitAddon?.fit();
|
||||
else this.syncTerminalGeometry?.();
|
||||
this._updateConnectionLinesImmediate?.();
|
||||
} catch (error) {
|
||||
console.warn('Failed to resize terminal after rail resize:', error);
|
||||
|
||||
+436
-48
@@ -551,6 +551,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.terminal.onRender(() => this._syncMobileHelperTextareaToCursor());
|
||||
}
|
||||
|
||||
// Renderer liveness — see _startRenderLivenessWatchdog. Registered for every
|
||||
// device, not just touch: the rAF-discard behaviour is worst on an iOS PWA
|
||||
// but a stale handle wedges the debouncer identically anywhere it happens.
|
||||
this.terminal.onRender(() => {
|
||||
this._lastRenderAt = Date.now();
|
||||
});
|
||||
this._startRenderLivenessWatchdog();
|
||||
|
||||
// CJK IME input — textarea in index.html, just wire up send
|
||||
this._cjkInput = null;
|
||||
if (typeof CjkInput !== 'undefined') {
|
||||
@@ -587,12 +595,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (isMobileSafari) {
|
||||
// Wait for layout, then fit multiple times to ensure proper sizing
|
||||
requestAnimationFrame(() => {
|
||||
this.fitAddon.fit();
|
||||
this.syncTerminalGeometry();
|
||||
// Double-check after another frame
|
||||
requestAnimationFrame(() => this.fitAddon.fit());
|
||||
requestAnimationFrame(() => this.syncTerminalGeometry());
|
||||
});
|
||||
} else {
|
||||
this.fitAddon.fit();
|
||||
this.syncTerminalGeometry();
|
||||
}
|
||||
// Whenever that first fit runs — on this line, or a frame or two later on
|
||||
// the mobile-Safari branch above — it measures whatever font the browser has
|
||||
@@ -764,6 +772,32 @@ Object.assign(CodemanApp.prototype, {
|
||||
let longPressStartX = 0;
|
||||
let longPressStartY = 0;
|
||||
let touchStartY = 0;
|
||||
let touchStartX = 0;
|
||||
// 'x' | 'y' | null — locked on the first travel past the tap slop, so a
|
||||
// diagonal drag cannot pan and scroll at the same time.
|
||||
let panAxis = null;
|
||||
/**
|
||||
* Can this gesture pan sideways? Only while the terminal is wider than
|
||||
* the box showing it (`.term-overflows-x`, set by
|
||||
* `_syncTerminalOverflowAffordance`).
|
||||
*
|
||||
* ⚠️ This has to be done in JS. `touch-action: pan-x` alone does nothing
|
||||
* for the sessions the affordance targets: `touchstart` calls
|
||||
* preventDefault() for every 'content' tap — the normal case for a
|
||||
* mouse-tracking TUI sitting at the bottom of its buffer — which cancels
|
||||
* the browser's pan before it starts. Measured under touch emulation, a
|
||||
* 140px horizontal swipe reached scrollLeft 141 without that
|
||||
* preventDefault and 0 with it. It only ever worked for shell sessions,
|
||||
* while scrolled up, or with a mouse.
|
||||
*/
|
||||
const canPanHorizontally = () =>
|
||||
// Both halves. The class is what makes the container a scroller at all
|
||||
// (`overflow-x: auto`); without it `scrollLeft` silently stays 0, and a
|
||||
// gesture locked to 'x' on that basis would do nothing AND suppress the
|
||||
// vertical scroll it should have been. The measurement is the second
|
||||
// half because sub-pixel cell widths can leave a stray pixel of
|
||||
// scrollWidth on a terminal that fits perfectly well.
|
||||
container.classList.contains('term-overflows-x') && container.scrollWidth - container.clientWidth > 1;
|
||||
let tapStartedWithTerminalFocus = false;
|
||||
let tapStartIntentCache = null;
|
||||
// px — ignore micro-drift to distinguish tap from scroll. Shared with the
|
||||
@@ -783,6 +817,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
touchLastX = ev.touches[0].clientX;
|
||||
touchLastY = ev.touches[0].clientY;
|
||||
touchStartY = touchLastY;
|
||||
touchStartX = touchLastX;
|
||||
panAxis = null;
|
||||
velocity = 0;
|
||||
pixelAccum = 0;
|
||||
isTouching = true;
|
||||
@@ -851,8 +887,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
if (ev.touches.length === 1 && isTouching) {
|
||||
const touchY = ev.touches[0].clientY;
|
||||
if (!didScroll && Math.abs(touchY - touchStartY) >= TAP_THRESHOLD) {
|
||||
didScroll = true;
|
||||
const touchX = ev.touches[0].clientX;
|
||||
if (!didScroll) {
|
||||
const travelY = Math.abs(touchY - touchStartY);
|
||||
const travelX = Math.abs(touchX - touchStartX);
|
||||
const sideways = canPanHorizontally() && travelX >= TAP_THRESHOLD;
|
||||
if (travelY >= TAP_THRESHOLD || sideways) {
|
||||
didScroll = true;
|
||||
panAxis = sideways && travelX > travelY ? 'x' : 'y';
|
||||
}
|
||||
}
|
||||
// Below the tap threshold, treat the gesture as a potential tap:
|
||||
// don't preventDefault (iOS needs click synthesis to show the
|
||||
@@ -862,6 +905,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
// fling, so a jittery tap would both position the cursor AND scroll.
|
||||
if (!didScroll) return;
|
||||
ev.preventDefault();
|
||||
if (panAxis === 'x') {
|
||||
// Pan the container, and touch nothing the vertical path owns —
|
||||
// no pixelAccum, no velocity, so touchend cannot turn a sideways
|
||||
// swipe into a momentum fling down the scrollback.
|
||||
container.scrollLeft -= touchX - touchLastX;
|
||||
touchLastX = touchX;
|
||||
touchLastY = touchY;
|
||||
return;
|
||||
}
|
||||
const delta = touchLastY - touchY; // positive = scroll down
|
||||
pixelAccum += delta;
|
||||
velocity = delta * 1.2;
|
||||
@@ -999,10 +1051,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._resizeTimeout = null;
|
||||
this._lastResizeDims = null;
|
||||
|
||||
// Minimum terminal dimensions to prevent vertical text wrapping
|
||||
const MIN_COLS = 40;
|
||||
const MIN_ROWS = 10;
|
||||
|
||||
const throttledResize = () => {
|
||||
if (this._tabRailResizeOwnsObserver) return;
|
||||
// Trailing-edge debounce: ALL resize work (fit + clear + SIGWINCH) happens
|
||||
@@ -1022,10 +1070,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
this._resizeTimeout = setTimeout(() => {
|
||||
this._resizeTimeout = null;
|
||||
// Fit xterm.js to final container dimensions
|
||||
if (this.fitAddon) {
|
||||
this.fitAddon.fit();
|
||||
}
|
||||
// Flush any stale flicker buffer before clearing viewport
|
||||
if (this.flickerFilterBuffer) {
|
||||
if (this.flickerFilterTimeout) {
|
||||
@@ -1034,24 +1078,35 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
this.flushFlickerBuffer();
|
||||
}
|
||||
// Skip server resize while mobile keyboard is visible — sending SIGWINCH
|
||||
// causes Ink to re-render at the new row count, garbling terminal output.
|
||||
// Local fit() still runs so xterm knows the viewport size for scrolling.
|
||||
// Hold the PTY's shape while the virtual keyboard is up: a SIGWINCH per
|
||||
// step of the OS animation makes Ink re-render at a row count that is
|
||||
// about to change again, and shifts the accessory toolbar mid-typing.
|
||||
// KeyboardHandler's settle timer sends ONE resize once the animation
|
||||
// stops (`_sendTerminalResize`), so the PTY is not left stale.
|
||||
const keyboardUp = typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible;
|
||||
// Same yield as sendResize: never resize a PTY whose session is showing
|
||||
// in its own window. Dragging the dashboard's border must not reshape it.
|
||||
const detachedElsewhere = !this.isSoloWindow && this.detachedSessions?.has(this.activeSessionId);
|
||||
if (this.activeSessionId && !keyboardUp && !detachedElsewhere) {
|
||||
const dims = this.fitAddon.proposeDimensions();
|
||||
// Enforce minimum dimensions to prevent layout issues
|
||||
const cols = dims ? Math.max(dims.cols, MIN_COLS) : MIN_COLS;
|
||||
const rows = dims ? Math.max(dims.rows, MIN_ROWS) : MIN_ROWS;
|
||||
// ⚠️ Whether to fit is the SAME question as whether to send (issue #464).
|
||||
// This block used to fit unconditionally and skip only the SIGWINCH,
|
||||
// which is the one combination that cannot be right: it moves xterm to
|
||||
// a shape the PTY is never told about, and Claude Code computes its
|
||||
// repaints from the shape it was told. Withhold both, or neither —
|
||||
// a reflow nothing is rendering for buys nothing and costs correctness.
|
||||
const dims = this.activeSessionId && !keyboardUp && !detachedElsewhere ? this.syncTerminalGeometry() : null;
|
||||
// ⚠️ A null measurement is NOT a reason to report the floor. It used to
|
||||
// fall back to a bare 40x10, which tells the PTY a shape nothing measured
|
||||
// and xterm does not hold — the write-only guess this whole change exists
|
||||
// to remove. An unmeasurable terminal has nothing to say; the next
|
||||
// resize event says it.
|
||||
if (dims) {
|
||||
const { cols, rows } = dims;
|
||||
// Only send resize if dimensions actually changed
|
||||
if (!this._lastResizeDims || cols !== this._lastResizeDims.cols || rows !== this._lastResizeDims.rows) {
|
||||
// Clear viewport + scrollback ONLY when dimensions actually change.
|
||||
// fitAddon.fit() reflows content: lines at old width may wrap to more rows,
|
||||
// pushing overflow into scrollback. Ink's cursor-up count is based on the
|
||||
// pre-reflow line count, so ghost renders accumulate in scrollback.
|
||||
// syncTerminalGeometry() reflowed content: lines at old width may wrap to
|
||||
// more rows, pushing overflow into scrollback. Ink's cursor-up count is
|
||||
// based on the pre-reflow line count, so ghost renders accumulate there.
|
||||
// Fix: \x1b[3J (Erase Saved Lines) clears scrollback reflow debris,
|
||||
// then \x1b[H\x1b[2J clears the viewport for a clean Ink redraw.
|
||||
// IMPORTANT: Only clear when we're actually sending SIGWINCH (dims changed).
|
||||
@@ -1084,11 +1139,24 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
}
|
||||
if (!sentViaWs) {
|
||||
// ⚠️ The reply carries the geometry that actually took, and this
|
||||
// is the path where a declined resize is LEAST likely to be
|
||||
// noticed: no socket means no `{"t":"zc"}` frame either, so
|
||||
// discarding it here left the one transport that cannot hear the
|
||||
// answer also not asking for it.
|
||||
const resizedSessionId = this.activeSessionId;
|
||||
fetch(`/api/sessions/${this.activeSessionId}/resize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cols, rows, viewportType }),
|
||||
}).catch(() => {});
|
||||
})
|
||||
.then(async (res) => {
|
||||
const applied = (await res.json())?.data ?? {};
|
||||
this._onPtyGeometryReport(resizedSessionId, applied.cols, applied.rows);
|
||||
})
|
||||
.catch(() => {
|
||||
/* a resize that never landed tells us nothing about the PTY */
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3349,7 +3417,107 @@ Object.assign(CodemanApp.prototype, {
|
||||
return performance.now() - this._lastUserScrollUpAt < window.CodemanTerminalInput.USER_SCROLL_STICKY_SUPPRESS_MS;
|
||||
},
|
||||
|
||||
/**
|
||||
* Watchdog for a frozen renderer.
|
||||
*
|
||||
* iOS DISCARDS scheduled requestAnimationFrame callbacks when a PWA goes to
|
||||
* the background — not deferred, never delivered. xterm's RenderDebouncer
|
||||
* only clears its `_animationFrame` handle from INSIDE that callback, so once
|
||||
* one is dropped the handle stays permanently non-undefined and every later
|
||||
* `refresh()` returns on its first line. Parsing is decoupled from rendering,
|
||||
* so bytes keep filling the buffer correctly and nothing throws: the terminal
|
||||
* is simply frozen until the page is reloaded.
|
||||
*
|
||||
* Codeman is more exposed than an app that mounts a terminal per session —
|
||||
* there is exactly ONE xterm instance for the whole page load, so a single
|
||||
* backgrounding can wedge it for the rest of the session.
|
||||
*
|
||||
* The heal is what `_innerRefresh` would have done: cancel the stale handle,
|
||||
* clear the field, and request a full repaint (which schedules a fresh rAF).
|
||||
* Cancelling a genuinely pending handle is harmless — the full repaint that
|
||||
* follows covers whatever it was going to draw.
|
||||
*
|
||||
* Discipline for reaching into xterm privates, and it is not optional: every
|
||||
* access is optional-chained and the whole body is wrapped, so a shape change
|
||||
* upstream degrades to a no-op. A self-heal that can break the terminal it is
|
||||
* healing is worse than no self-heal.
|
||||
*
|
||||
* ⚠️ The field path (`_core._renderService._renderDebouncer._animationFrame`)
|
||||
* is validated against xterm 6.x and CANNOT be covered by the CI gate:
|
||||
* `_renderService` is only constructed by `Terminal.open()`, which needs a
|
||||
* real DOM, and the gate runs in node. `test/xterm-private-api.test.ts` pins
|
||||
* the RESOLVED lockfile version instead, so ANY bump fails there — not only a
|
||||
* major — and sends someone to re-check this by hand; the declared `^6.0.0`
|
||||
* range was the wrong assertion in both directions, since 6.4.0 could rename a
|
||||
* private field while resolving inside it. `test/terminal-resilience.test.ts`
|
||||
* covers the decision half. If the path ever goes stale the watchdog silently
|
||||
* stops healing — that is the failure mode to watch for, and why the version
|
||||
* guard exists at all.
|
||||
*/
|
||||
_startRenderLivenessWatchdog() {
|
||||
this._stopRenderLivenessWatchdog();
|
||||
this._lastRenderAt = Date.now();
|
||||
this._lastTerminalWriteAt = 0;
|
||||
this._renderLivenessTimer = setInterval(() => {
|
||||
try {
|
||||
if (typeof CodemanRenderLiveness === 'undefined') return;
|
||||
const kick = CodemanRenderLiveness.shouldKickRenderer({
|
||||
wroteAt: this._lastTerminalWriteAt || 0,
|
||||
renderedAt: this._lastRenderAt || 0,
|
||||
now: Date.now(),
|
||||
// A hidden terminal legitimately stops rendering (xterm pauses it),
|
||||
// so only a VISIBLE one that owes us a frame counts as frozen.
|
||||
visible: document.visibilityState === 'visible' && !!this.terminal?.element?.isConnected,
|
||||
});
|
||||
if (!kick) return;
|
||||
const kicked = this._kickRenderer();
|
||||
_crashDiag.log(`RENDER STALL: kick=${kicked}`);
|
||||
// Treat the kick as the render for accounting purposes either way, so a
|
||||
// terminal we cannot heal logs once per stall rather than every tick.
|
||||
this._lastRenderAt = Date.now();
|
||||
} catch {
|
||||
/* a watchdog must never throw into the interval */
|
||||
}
|
||||
}, RENDER_LIVENESS_POLL_MS);
|
||||
},
|
||||
|
||||
_stopRenderLivenessWatchdog() {
|
||||
if (this._renderLivenessTimer) {
|
||||
clearInterval(this._renderLivenessTimer);
|
||||
this._renderLivenessTimer = null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Do what xterm's dropped `_innerRefresh` would have done. Never throws.
|
||||
* @returns {boolean} true if a stale handle was found and cleared.
|
||||
*/
|
||||
_kickRenderer() {
|
||||
try {
|
||||
const renderService = this.terminal?._core?._renderService;
|
||||
const debouncer = renderService?._renderDebouncer;
|
||||
if (!debouncer || typeof renderService.refreshRows !== 'function') return false;
|
||||
const handle = debouncer._animationFrame;
|
||||
if (handle === undefined) return false; // not wedged — nothing to clear
|
||||
try {
|
||||
cancelAnimationFrame(handle);
|
||||
} catch {
|
||||
/* a stale handle may no longer be cancellable; clearing it is the point */
|
||||
}
|
||||
debouncer._animationFrame = undefined;
|
||||
renderService.refreshRows(0, Math.max(0, (this.terminal.rows || 1) - 1));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
batchTerminalWrite(data) {
|
||||
// Feed the renderer watchdog. Recorded before the buffer-load early return
|
||||
// below: a write that is queued rather than written still means the pipeline
|
||||
// owes us a frame once it drains.
|
||||
this._lastTerminalWriteAt = Date.now();
|
||||
|
||||
// If a buffer load (chunkedTerminalWrite) is in progress, queue live events
|
||||
// to prevent interleaving historical buffer data with live SSE data.
|
||||
// This is critical: interleaving causes cursor position chaos with Ink redraws.
|
||||
@@ -5223,7 +5391,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
setFontSize(size) {
|
||||
this.terminal.options.fontSize = size;
|
||||
document.getElementById('fontSizeDisplay').textContent = size;
|
||||
this.fitAddon.fit();
|
||||
this._refitAfterCellSizeChange();
|
||||
localStorage.setItem('codeman-font-size', size);
|
||||
// Update overlay font cache and re-render at new cell dimensions
|
||||
this._localEchoOverlay?.refreshFont();
|
||||
@@ -5252,9 +5420,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
// without needing a tab switch. The fit below still runs, so the terminal
|
||||
// is never left unfitted if the wait is slow.
|
||||
this._terminalFontReady = this._awaitTerminalFont().then(() => {
|
||||
if (this.terminal?.options?.fontFamily === resolved) this.fitAddon?.fit();
|
||||
if (this.terminal?.options?.fontFamily === resolved) this._refitAfterCellSizeChange();
|
||||
});
|
||||
this.fitAddon?.fit();
|
||||
this._refitAfterCellSizeChange();
|
||||
this._localEchoOverlay?.refreshFont();
|
||||
this._predictiveEcho?.refreshFont();
|
||||
if (this._splitPane?.terminal) {
|
||||
@@ -5297,9 +5465,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
// rasterized yet. Re-arm the wait and fit again once it settles; the fit
|
||||
// below still runs, so the terminal is never left unfitted.
|
||||
this._terminalFontReady = this._awaitTerminalFont().then(() => {
|
||||
if (this.terminal?.options?.fontWeight === fontWeight) this.fitAddon?.fit();
|
||||
if (this.terminal?.options?.fontWeight === fontWeight) this._refitAfterCellSizeChange();
|
||||
});
|
||||
this.fitAddon?.fit();
|
||||
this._refitAfterCellSizeChange();
|
||||
this._localEchoOverlay?.refreshFont();
|
||||
this._predictiveEcho?.refreshFont();
|
||||
for (const [, entry] of this.teammateTerminals || []) {
|
||||
@@ -5390,19 +5558,99 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
/**
|
||||
* Get terminal dimensions with minimum enforcement.
|
||||
* Prevents extremely narrow terminals that cause vertical text wrapping.
|
||||
* The geometry this terminal would report right now, floors applied.
|
||||
* Reads only — `syncTerminalGeometry()` is what makes it true of xterm.
|
||||
* @returns {{cols: number, rows: number}|null}
|
||||
*/
|
||||
getTerminalDimensions() {
|
||||
const MIN_COLS = 40;
|
||||
const MIN_ROWS = 10;
|
||||
const dims = this.fitAddon?.proposeDimensions();
|
||||
// Never throws. `proposeDimensions()` reads a rendered element and throws
|
||||
// on a terminal that has been disposed or detached mid-resize, which is an
|
||||
// ordinary outcome on a tab switch — and this is called from the settle
|
||||
// timer and the resize observer, where an exception takes the rest of the
|
||||
// callback (the padding fit, the scroll restore, the SIGWINCH) with it.
|
||||
try {
|
||||
return window.CodemanTerminalGeometry.clampTerminalDimensions(this.fitAddon?.proposeDimensions());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fit xterm to its container and return the geometry that was APPLIED.
|
||||
*
|
||||
* ⚠️ THE ONLY function that may change the terminal's size, and the only
|
||||
* source of the numbers sent to the server. `fitAddon.fit()` on its own is
|
||||
* not enough and the gap is issue #464: fit() resizes xterm to
|
||||
* `proposeDimensions()` RAW, while every server-facing path reported those
|
||||
* dimensions floored at 40x10. Whenever the floor bit — a phone with the
|
||||
* keyboard up routinely proposes under ten rows — the PTY was told one shape
|
||||
* and xterm held another, and Claude Code then computed every repaint for a
|
||||
* screen that did not exist. See the note in constants.js for what that
|
||||
* renders as, and why the floor is not negotiable at either end.
|
||||
*
|
||||
* Three call sites each used to do their own fit-then-clamp
|
||||
* (`throttledResize`, `sendResize`, KeyboardHandler's one-shot), which is
|
||||
* three chances to disagree; two of them also re-read `proposeDimensions()`
|
||||
* after the fit, so a container that moved in between — `_shrinkPaddingToFit`
|
||||
* runs exactly there — changed the answer without touching xterm.
|
||||
*
|
||||
* The second resize only happens when the floor actually bites, so the
|
||||
* ordinary path still reflows once, as before.
|
||||
*
|
||||
* @returns {{cols: number, rows: number}|null} null when the terminal cannot be measured
|
||||
*/
|
||||
syncTerminalGeometry() {
|
||||
if (!this.fitAddon || !this.terminal) return null;
|
||||
try {
|
||||
this.fitAddon.fit();
|
||||
} catch {
|
||||
/* a disposed or unattached terminal cannot be fitted; fall through to the read */
|
||||
}
|
||||
const dims = this.getTerminalDimensions();
|
||||
if (!dims) return null;
|
||||
return {
|
||||
cols: Math.max(dims.cols, MIN_COLS),
|
||||
rows: Math.max(dims.rows, MIN_ROWS),
|
||||
};
|
||||
if (!this._resizeTerminalTo(dims)) return null;
|
||||
// The floor can leave this terminal wider than the box that shows it, and
|
||||
// that clips columns with no gesture to reach them (issue #464, item 4).
|
||||
this._scheduleOverflowAffordanceSync();
|
||||
return dims;
|
||||
},
|
||||
|
||||
/**
|
||||
* Re-measure after something changed the CELL size, and tell the server.
|
||||
*
|
||||
* ⚠️ A font change is a geometry change. Bigger glyphs mean fewer columns in
|
||||
* the same box, and the PTY is drawing for a column count nobody updated:
|
||||
* `setFontSize`, `setFontFamily` and `setFontWeight` all refitted the terminal
|
||||
* and sent NOTHING, so raising the font on a phone could drop the browser
|
||||
* below the columns the CLI was still wrapping at until some unrelated resize
|
||||
* event happened along. That is issue #464 reached through the font menu.
|
||||
*
|
||||
* With no session there is no PTY to tell, and a session detached into its own
|
||||
* window is not this terminal's to resize — `sendResize` makes that call, and
|
||||
* fits as its first synchronous step, so this never fits twice.
|
||||
*/
|
||||
_refitAfterCellSizeChange() {
|
||||
if (this.activeSessionId) {
|
||||
this.sendResize(this.activeSessionId)?.catch?.(() => {});
|
||||
return;
|
||||
}
|
||||
this.syncTerminalGeometry();
|
||||
},
|
||||
|
||||
/**
|
||||
* Make xterm exactly `dims`. Idempotent, and never throws at a caller — a
|
||||
* terminal disposed mid-resize is an ordinary outcome on a tab switch.
|
||||
* @returns {{cols: number, rows: number}|null} the applied geometry
|
||||
*/
|
||||
_resizeTerminalTo(dims) {
|
||||
if (!this.terminal || !dims) return null;
|
||||
if (this.terminal.cols === dims.cols && this.terminal.rows === dims.rows) return dims;
|
||||
try {
|
||||
this.terminal.resize(dims.cols, dims.rows);
|
||||
return dims;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -5412,21 +5660,25 @@ Object.assign(CodemanApp.prototype, {
|
||||
* @returns {Promise<boolean>} Whether dimensions changed from the last send
|
||||
*/
|
||||
async sendResize(sessionId, options = {}) {
|
||||
// Fit terminal to container before reading dimensions — ensures local
|
||||
// terminal size matches what we report to the server PTY.
|
||||
if (this.fitAddon) this.fitAddon.fit();
|
||||
// One PTY cannot hold two sizes. A detached session is owned by its own
|
||||
// window, and the dashboard's terminal is narrower than that window because
|
||||
// the session rail takes width the popup does not have — so both sizing it
|
||||
// makes the CLI draw frames that fit neither, which garbles the popup. The
|
||||
// dashboard yields; the solo window sizes what it alone displays.
|
||||
// (_maybeRefetchFullHistory already stands aside for the same reason.)
|
||||
// ⚠️ AFTER the fit, never before: the local reflow keeps the dashboard's own
|
||||
// xterm right, and only the SERVER write is the dashboard's to withhold —
|
||||
// the mobile-keyboard guard below draws exactly this line. tab-rail-resize
|
||||
// performs its one settle-time refit through this call and has no fallback.
|
||||
// ⚠️ BEFORE the fit, never after. This used to fit first and withhold only
|
||||
// the server write, on the reasoning that the local reflow keeps the
|
||||
// dashboard's own xterm right. It does not: it leaves this xterm at a shape
|
||||
// the PTY was never told about, which is the #464 divergence exactly — and
|
||||
// the popup that DOES own the PTY is drawing for its own width, so the
|
||||
// dashboard's reflow is to a size nothing is rendering for. Withholding the
|
||||
// resize means withholding all of it. tab-rail-resize performs its one
|
||||
// settle-time refit through this call and has no fallback, which is correct:
|
||||
// a pane it does not own is not its to refit either.
|
||||
if (!this.isSoloWindow && this.detachedSessions?.has(sessionId)) return false;
|
||||
const dims = this.getTerminalDimensions();
|
||||
// Fit, floor, and apply in one step so the numbers below are the numbers
|
||||
// xterm is actually holding.
|
||||
const dims = this.syncTerminalGeometry();
|
||||
if (!dims) return false;
|
||||
// Did the dimensions actually change since the last resize we sent? Callers
|
||||
// use this to skip work (e.g. the post-resize TUI-redraw settle) when no
|
||||
@@ -5459,14 +5711,150 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
const body = { ...dims, viewportType };
|
||||
if (options.force) body.force = true;
|
||||
await fetch(`/api/sessions/${sessionId}/resize`, {
|
||||
const res = await fetch(`/api/sessions/${sessionId}/resize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
// Same report the WS path gets as a {"t":"zc"} frame. An older server
|
||||
// answers `{}`, which reconciles to a no-op rather than throwing.
|
||||
try {
|
||||
const applied = (await res.json())?.data ?? {};
|
||||
this._onPtyGeometryReport(sessionId, applied.cols, applied.rows);
|
||||
} catch {
|
||||
/* a body that is not JSON tells us nothing about the PTY; keep our own geometry */
|
||||
}
|
||||
return changed;
|
||||
},
|
||||
|
||||
/**
|
||||
* Adopt the geometry the server says the PTY actually has.
|
||||
*
|
||||
* ⚠️ The server is the authority and this client is not always obeyed.
|
||||
* `Session.resize` declines a small-viewport request outright while a desktop
|
||||
* connection holds an active sizing claim, and says nothing — resize was
|
||||
* write-only until #464. A terminal that keeps its own shape after such a
|
||||
* refusal does not render "too narrow", it renders GARBLED: Claude Code wraps
|
||||
* its frame at the width it was told and walks the cursor up that many rows,
|
||||
* so a mismatch makes its erase count come out short and each repaint paints
|
||||
* over rows it never cleared. Measured against a real xterm — a PTY believing
|
||||
* 120 columns against a 62-column terminal draws every wrapped line twice.
|
||||
*
|
||||
* Adopting can leave the pane wider than the viewport, and the container is
|
||||
* `overflow: hidden`, so `.term-overflows-x` grants horizontal reach for exactly
|
||||
* as long as the mismatch lasts. Correct-and-reachable beats correct-and-
|
||||
* clipped beats garbled; nothing here is worth trapping content behind.
|
||||
*
|
||||
* Self-resolving: `_startMobileResizeRetry` re-sends this device's dimensions
|
||||
* on a timer, so the pane comes back to this screen once the desktop goes
|
||||
* idle, and the next report clears the class and the notice with it.
|
||||
*/
|
||||
_onPtyGeometryReport(sessionId, cols, rows) {
|
||||
if (!this.terminal || sessionId !== this.activeSessionId) return;
|
||||
const local = { cols: this.terminal.cols, rows: this.terminal.rows };
|
||||
const { adopt } = window.CodemanTerminalGeometry.reconcilePtyGeometry(local, { cols, rows });
|
||||
// Columns only, and the local row count is kept — see reconcilePtyGeometry
|
||||
// for why adopting rows put the CLI's input line below the container with
|
||||
// nothing able to scroll to it.
|
||||
if (adopt && this._resizeTerminalTo({ cols, rows: local.rows })) {
|
||||
// The numbers we would report next are now the PTY's, not the container's:
|
||||
// without this the dedupe in throttledResize/sendResize compares against a
|
||||
// request that was refused and suppresses the retry that recovers the pane.
|
||||
this._lastResizeDims = { cols, rows: local.rows };
|
||||
}
|
||||
// Is the PTY at a width this container did not ask for? Compared against
|
||||
// what we WOULD request, not against what the terminal currently holds:
|
||||
// once adopted those two are equal, so the second question answers itself
|
||||
// false and the condition would look resolved while it is still true.
|
||||
// The floor widens this terminal too, and that is the reader's own font
|
||||
// setting rather than another device — hence the comparison, not `>`.
|
||||
const wanted = this.getTerminalDimensions();
|
||||
this._paneWidthRefused = !!wanted && Number.isFinite(cols) && cols !== wanted.cols;
|
||||
this._scheduleOverflowAffordanceSync();
|
||||
},
|
||||
|
||||
/**
|
||||
* Measure on the NEXT frame, coalesced.
|
||||
*
|
||||
* `terminal.resize()` updates the buffer synchronously but the screen element
|
||||
* takes its new width with the render, so measuring in the same tick reads
|
||||
* the size the terminal just left. Coalesced because a settling container
|
||||
* fires several resizes and only the last one's measurement is the truth.
|
||||
*/
|
||||
_scheduleOverflowAffordanceSync() {
|
||||
if (typeof requestAnimationFrame !== 'function') {
|
||||
this._syncTerminalOverflowAffordance();
|
||||
return;
|
||||
}
|
||||
if (this._overflowAffordanceFrame) return;
|
||||
this._overflowAffordanceFrame = requestAnimationFrame(() => {
|
||||
this._overflowAffordanceFrame = null;
|
||||
this._syncTerminalOverflowAffordance();
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Let the reader reach a pane wider than the box that shows it.
|
||||
*
|
||||
* ⚠️ Keyed on what actually does not FIT, not on a PTY mismatch. Two
|
||||
* different causes put the terminal wider than its container and both leave
|
||||
* columns unreachable behind `.terminal-container`'s clip:
|
||||
*
|
||||
* - another device holds the sizing claim, so this terminal adopts a width
|
||||
* it did not ask for; and
|
||||
* - the 40-column floor. On a 360px phone, font 18 applies 40 columns and
|
||||
* paints 433px, and font 24 paints 578px — 218px, 38% of the pane, with no
|
||||
* gesture that could reach it. `increaseFontSize` goes to 24 and applies
|
||||
* immediately, so that is two taps away, and the PTY agrees with the
|
||||
* terminal throughout: a mismatch test would never fire.
|
||||
*
|
||||
* Measured rather than derived from cell arithmetic, because the cell width
|
||||
* is fractional and the container's padding is not ours to assume. One pixel
|
||||
* of slack keeps sub-pixel rounding from flapping the class.
|
||||
*/
|
||||
_syncTerminalOverflowAffordance() {
|
||||
// ⚠️ Nothing in here may throw. It runs off every geometry change, which is
|
||||
// the resize path, and the affordance is cosmetic: a terminal that cannot
|
||||
// be measured — disposed mid-resize, or a harness with no real DOM — must
|
||||
// lose the scroll affordance, never the resize.
|
||||
let container = null;
|
||||
let overflows = false;
|
||||
try {
|
||||
container = document.getElementById('terminalContainer');
|
||||
const screen = container?.querySelector('.xterm-screen');
|
||||
if (container && screen) {
|
||||
overflows = screen.getBoundingClientRect().width - container.clientWidth > 1;
|
||||
}
|
||||
} catch {
|
||||
/* unmeasurable; fall through with the affordance off */
|
||||
}
|
||||
container?.classList.toggle('term-overflows-x', overflows);
|
||||
// The notice tells the reader to scroll sideways, so it is only true advice
|
||||
// once there is something to scroll. A wide PTY on a screen wide enough to
|
||||
// show it needs no explanation and gets none.
|
||||
if (!overflows || !this._paneWidthRefused) {
|
||||
this._paneOwnedElsewhere = false;
|
||||
return;
|
||||
}
|
||||
this._notePaneOwnedElsewhere();
|
||||
},
|
||||
|
||||
/**
|
||||
* Say, once, that this pane's width belongs to another device.
|
||||
*
|
||||
* Once per transition, not per report: reports arrive on every resize, and a
|
||||
* toast that repeats is noise about a situation already on screen. Silent
|
||||
* when it resolves — the pane simply reflows back to this screen.
|
||||
*/
|
||||
_notePaneOwnedElsewhere() {
|
||||
if (this._paneOwnedElsewhere) return;
|
||||
this._paneOwnedElsewhere = true;
|
||||
// 53 characters: measured at one line on a 430px phone. The longer
|
||||
// wording wrapped to two, which is a lot of the terminal to cover for a
|
||||
// notice about a condition that resolves itself.
|
||||
this.showToast('Another device is setting the width — scroll sideways', 'info');
|
||||
},
|
||||
|
||||
/**
|
||||
* Send input to the active session.
|
||||
* @param {string} input - Text to send (include \r for Enter)
|
||||
|
||||
@@ -2124,7 +2124,13 @@ export function registerSessionRoutes(
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
session.resize(cols, rows, { viewportType, force });
|
||||
return {};
|
||||
// Answer with the geometry the PTY ACTUALLY holds, which is not always the
|
||||
// one asked for: `Session.resize` declines small-viewport requests while a
|
||||
// desktop connection holds an active sizing claim. A browser terminal left
|
||||
// at a shape the PTY refused renders garbled output, not merely wrong-sized
|
||||
// output, so the client adopts this (issue #464). A session with no pane
|
||||
// reports nothing rather than the constructor defaults — see `ptyGeometry`.
|
||||
return session.ptyGeometry ?? {};
|
||||
});
|
||||
|
||||
// ========== Get Last Response (from transcript JSONL) ==========
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
* {"t":"c"} — clear terminal
|
||||
* {"t":"r"} — needs refresh (reload buffer)
|
||||
* {"t":"ia","seq":N} — input ACK (echoes the seq of an applied/deduped input frame)
|
||||
* {"t":"zc","c":N,"r":N} — resize confirm: the geometry the PTY now holds, which
|
||||
* is NOT always the one requested (see Session.resize
|
||||
* arbitration). Clients adopt it — issue #464.
|
||||
* Client -> Server:
|
||||
* {"t":"i","d":"...","seq":N,"cid":"..."} — input (keystroke or paste). seq+cid are
|
||||
* optional reliable-delivery tags: the server applies each
|
||||
@@ -240,6 +243,19 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
|
||||
}
|
||||
const force = msg.f === true;
|
||||
session.resize(msg.c, msg.r, { viewportType, force });
|
||||
// Report the geometry that actually took. Resize used to be
|
||||
// write-only, so a client whose request was declined by the
|
||||
// arbitration above — or floored, or overridden by another device
|
||||
// — had no way to find out, and went on rendering a CLI's repaints
|
||||
// against a screen shape that did not exist (issue #464). Sent
|
||||
// unconditionally: it is ~30 bytes on a debounced, rare message,
|
||||
// and always-send means the client needs no "did it take?" state.
|
||||
// A session with no pane sends nothing at all: its `_ptyCols`/
|
||||
// `_ptyRows` are constructor defaults no process was ever told.
|
||||
const applied = session.ptyGeometry;
|
||||
if (applied && socket.readyState === 1) {
|
||||
socket.send(`{"t":"zc","c":${applied.cols},"r":${applied.rows}}`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed messages
|
||||
|
||||
Reference in New Issue
Block a user