fix(input): an oversized paste no longer poisons the durable input queue (#484)

A single input over MAX_INPUT_LENGTH (64 KiB) was queued for reliable
delivery, refused by both transports (the WebSocket silently, POST with a
400), and never dropped: the client treated the 400 as transient, so the
frame was re-sent every 2 s forever, blocked every later input for that
session, and came back from localStorage on every reload.

- Client: a paste over the frame limit is split into in-limit frames
  (never cutting a surrogate pair) delivered in seq order; over 1 MiB, or
  an oversized mux write, it is refused with a toast and never queued.
- Client: the POST drain drops a frame answered 400/413; a WS error ACK
  drops it too; frames over the limit persisted by an older build are
  pruned on load.
- Server: the WebSocket answers an oversized sequenced frame with
  {t:'ia',seq,err:'too_large',max} instead of silence (an older client
  reads that as a plain ACK and drops it); the POST schema uses
  MAX_INPUT_LENGTH instead of a second 100000 limit.

Verified end to end on an isolated instance: a 110 KB paste reached the
PTY byte-identical over both the WebSocket and the POST path, a poisoned
120 KB persisted frame was pruned on load, and a 2 MB paste showed the
refusal toast with nothing queued.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-09-24 18:17:06 +02:00
parent e6ddb0485a
commit d67da5c9d0
7 changed files with 269 additions and 17 deletions
+55 -15
View File
@@ -3427,7 +3427,26 @@ class CodemanApp {
*/
_sendInputAsync(sessionId, input, opts) {
if (!sessionId || !input) return;
this._reliableSend(sessionId, input, opts?.useMux === true);
const useMux = opts?.useMux === true;
// Both transports refuse a frame over the server's limit (issue #484), and a
// refused frame used to sit at the head of the durable queue for good. So an
// oversized paste goes out as several in-limit frames, delivered in seq order
// as one contiguous stream. A mux write is line-oriented (it strips newlines
// and sends Enter on its own), so it is never split: refuse it instead.
const limit = window.CodemanInputLimit;
if (limit && input.length > limit.FRAME_MAX_CHARS) {
if (useMux || input.length > limit.PASTE_MAX_CHARS) {
const max = useMux ? limit.FRAME_MAX_CHARS : limit.PASTE_MAX_CHARS;
this.showToast?.(
`Input too large (${Math.ceil(input.length / 1024)} KB, limit ${Math.floor(max / 1024)} KB); not sent`,
'error'
);
return;
}
for (const frame of limit.split(input)) this._reliableSend(sessionId, frame, false);
return;
}
this._reliableSend(sessionId, input, useMux);
}
/**
@@ -3547,6 +3566,12 @@ class CodemanApp {
// Session no longer exists — the input can never land. Drop it
// rather than retry forever (not a "lost" prompt: the target is gone).
this._ackDelivery(sessionId, rec.seq);
} else if (resp && (resp.status === 400 || resp.status === 413)) {
// The frame itself was refused, so a retry gets the same answer. Kept
// queued, it was re-POSTed every 2 s forever and blocked every later
// input for this session behind it (issue #484). 401/403 stay
// transient: an expired login delivers fine once the user signs in.
this._dropRejectedInput(sessionId, rec);
} else {
break; // offline / 5xx — leave queued; sweep + reconnect retry later
}
@@ -3557,6 +3582,12 @@ class CodemanApp {
})();
}
/** Drop a frame the server refused for good, and say so once. */
_dropRejectedInput(sessionId, rec) {
this._ackDelivery(sessionId, rec.seq);
this.showToast?.(`Input refused by the server (${Math.ceil(rec.data.length / 1024)} KB); not sent`, 'error');
}
/** Drop an ACKed record (by exact seq) and persist. */
_ackDelivery(sessionId, seq) {
const list = this._pendingDeliveries.get(sessionId);
@@ -3601,6 +3632,13 @@ class CodemanApp {
_onWsInputAck(seq, msg) {
const sessionId = this._wsSessionId;
if (!sessionId || !Number.isInteger(seq)) return;
if (msg && msg.err) {
// Refused for good (e.g. over the size limit): retrying cannot help.
const rec = (this._pendingDeliveries.get(sessionId) || []).find((r) => r.seq === seq);
if (rec) this._dropRejectedInput(sessionId, rec);
else this._ackDelivery(sessionId, seq);
return;
}
if (msg && msg.dup) {
const list = this._pendingDeliveries.get(sessionId);
const rec = list && list.find((r) => r.seq === seq);
@@ -3714,20 +3752,22 @@ class CodemanApp {
if (saved && saved.pending) {
for (const [s, recs] of Object.entries(saved.pending)) {
if (Array.isArray(recs) && recs.length) {
// Reset sentAt so they re-deliver promptly on this fresh load.
this._pendingDeliveries.set(
s,
recs
.filter((r) => r && typeof r.data === 'string' && Number.isInteger(r.seq))
.map((r) => ({
seq: r.seq,
data: r.data,
useMux: !!r.useMux,
ts: r.ts || Date.now(),
tries: 0,
sentAt: 0,
}))
);
const frameMax = window.CodemanInputLimit?.FRAME_MAX_CHARS ?? Infinity;
const kept = recs
.filter((r) => r && typeof r.data === 'string' && Number.isInteger(r.seq))
// A frame over the server's limit can never be ACKed; one persisted
// by an older build would otherwise come back on every load (#484).
.filter((r) => r.data.length <= frameMax)
// Reset sentAt so they re-deliver promptly on this fresh load.
.map((r) => ({
seq: r.seq,
data: r.data,
useMux: !!r.useMux,
ts: r.ts || Date.now(),
tries: 0,
sentAt: 0,
}));
if (kept.length) this._pendingDeliveries.set(s, kept);
}
}
}