Compare commits

...
Author SHA1 Message Date
Codeman maintainer d55bc28a8e feat(mobile): search box in the Select Case picker
The phone case picker had no way to narrow a long case list, so finding
one meant scrolling a sheet that showed about six rows at a time.

- A search field filters rows by name (every typed word must match, any
  order, case-insensitive), with a "No matching cases" state. Enter picks
  the case when exactly one row is left; Escape clears, then closes.
- The field is not auto-focused, so opening the picker does not raise the
  keyboard. The list holds its unfiltered height while searching so the
  sheet does not jump, and the input is 16px so iOS Safari does not zoom.
- Layout: the sheet padded the home-indicator inset on top of the footer
  already doing so, leaving a dead band under Create New Case; the sheet
  now grows to 80dvh and the list fills it instead of a separate 50vh cap.
- Opening scrolls the list (its own box, not scrollIntoView) to the
  currently selected case.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 19:56:23 +02:00
Codeman maintainer d67da5c9d0 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>
2026-09-24 18:17:06 +02:00
12 changed files with 385 additions and 20 deletions
+27
View File
@@ -66,6 +66,31 @@ each `(clientId, seq)` at most once, so a resend can't type the prompt twice.
(the 200 is the client's ACK). `curl`/legacy callers omit the fields and always
apply.
## Oversized input (issue #484)
Delivery has a third outcome besides "applied" and "retry": **refused for good**.
Both transports refuse a frame longer than `MAX_INPUT_LENGTH` (64 KiB,
`src/config/terminal-limits.ts`; the POST schema uses the same constant). Before
#484 the client treated that like a transient failure, so an oversized paste sat
at the head of the queue, was re-sent every 2 s forever, blocked every later
input for the session, and came back from localStorage on each reload.
- `_sendInputAsync()` splits a paste over the frame limit into in-limit frames
(`CodemanInputLimit.split`, constants.js, never cutting a surrogate pair). They
go out in seq order, so the PTY sees one contiguous stream. A paste over
`PASTE_MAX_CHARS` (1 MiB), or an oversized `useMux` write (line-oriented, never
split), is refused with a toast and never queued.
- The WebSocket answers an oversized sequenced frame with
`{t:'ia', seq, err:'too_large', max}`; the client drops it with a toast. A
client that predates `err` reads it as a plain ACK and drops it too.
- The POST drain drops a frame answered `400`/`413` (`401`/`403` stay transient:
an expired login delivers once the user signs in again).
- `_loadReliableState()` prunes persisted frames over the limit, so a queue
poisoned by an older build heals on the first load after upgrading.
- ⚠️ The frontend limit (`INPUT_FRAME_MAX_CHARS`) and the composer's
`COMPOSER_INPUT_FRAME_LIMIT` must equal `MAX_INPUT_LENGTH`; pinned by
`test/input-size-limit.test.ts`.
## Known limitation
Dedup state is in-memory on the server. A **server restart** between a write and
@@ -79,3 +104,5 @@ across the narrow restart window.
semantics (monotonic, per-client, gap-tolerant, eviction-safe).
- `test/routes/session-routes.test.ts` — POST `/input` applies a tagged
`(clientId, seq)` once on redelivery; untagged input always applies.
- `test/input-size-limit.test.ts`: one input limit on both sides, frame
splitting, and dropping (never retrying) a frame refused for good (#484).
+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);
}
}
}
+48
View File
@@ -762,6 +762,49 @@ function resolveTerminalFontWeights(settings) {
// without a terminal, a clipboard, or a browser.
// ---------------------------------------------------------------------------
/**
* Largest single input frame the server accepts, in UTF-16 code units.
* ⚠️ Must equal MAX_INPUT_LENGTH in src/config/terminal-limits.ts (pinned by
* test/input-size-limit.test.ts). Both transports reject a longer frame, and
* before issue #484 the durable input queue retried such a frame forever.
*/
const INPUT_FRAME_MAX_CHARS = 64 * 1024;
/**
* Largest paste the client will deliver at all. Anything up to this is split
* into INPUT_FRAME_MAX_CHARS frames that go out in seq order, so the PTY sees
* one contiguous byte stream (bracketed-paste markers included). Past it the
* input is refused with a toast rather than queued: every frame is persisted
* and retried until ACKed, so a multi-megabyte paste would pin the queue.
*/
const INPUT_PASTE_MAX_CHARS = 1024 * 1024;
/**
* Split input into frames no longer than `max` code units, never cutting a
* surrogate pair in half (a lone surrogate reaches the PTY as U+FFFD).
*
* @param {string} data
* @param {number} [max]
* @returns {string[]}
*/
function splitInputFrames(data, max = INPUT_FRAME_MAX_CHARS) {
if (typeof data !== 'string' || data.length === 0) return [];
if (!(max >= 2)) max = 2;
if (data.length <= max) return [data];
const frames = [];
let start = 0;
while (start < data.length) {
let end = Math.min(start + max, data.length);
if (end < data.length) {
const code = data.charCodeAt(end - 1);
if (code >= 0xd800 && code <= 0xdbff) end--; // keep the pair together
}
frames.push(data.slice(start, end));
start = end;
}
return frames;
}
/**
* Upper bound on an AUTO-copied selection.
*
@@ -940,6 +983,11 @@ if (typeof window !== 'undefined') {
compare: compareSessionActivity,
sort: sortSessionsByActivity,
};
window.CodemanInputLimit = {
FRAME_MAX_CHARS: INPUT_FRAME_MAX_CHARS,
PASTE_MAX_CHARS: INPUT_PASTE_MAX_CHARS,
split: splitInputFrames,
};
window.CodemanAutoCopy = {
decide: decideAutoCopy,
MAX_CHARS: AUTO_COPY_MAX_CHARS,
+2
View File
@@ -106,6 +106,8 @@
'Manage AI Coding tools in persistent tmux sessions.': '在持久化 tmux 会话中管理 AI 编程工具。',
'Select case': '选择案例',
'Select Case': '选择案例',
'Search cases': '搜索案例',
'No matching cases': '没有匹配的案例',
'All cases': '全部案例',
'No directory': '未选择目录',
Run: '运行',
+5
View File
@@ -3204,7 +3204,12 @@
<h3>Select Case</h3>
<button class="modal-close" onclick="app.closeMobileCasePicker()" aria-label="Close case picker">&times;</button>
</div>
<div class="mobile-case-picker-search">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="search" id="mobileCaseSearch" placeholder="Search cases" aria-label="Search cases" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" enterkeyhint="go" oninput="app.filterMobileCases()" onkeydown="app.onMobileCaseSearchKey(event)">
</div>
<div class="mobile-case-picker-body">
<div class="mobile-case-empty" id="mobileCaseEmpty" hidden>No matching cases</div>
<div class="mobile-case-list" id="mobileCaseList">
<!-- Cases populated by JS -->
</div>
+4 -2
View File
@@ -2165,9 +2165,11 @@ html.mobile-init .file-browser-panel {
background: rgba(0, 0, 0, 0.5);
}
/* The footer already reserves the home-indicator inset; padding the sheet too
counted it twice and left a dead band under Create New Case. */
.mobile-case-picker-sheet {
max-height: 60vh;
padding-bottom: var(--safe-area-bottom);
max-height: 80vh;
max-height: 80dvh;
animation: slideUp 0.2s ease-out;
}
+55
View File
@@ -4506,6 +4506,7 @@ Object.assign(CodemanApp.prototype, {
const isSelected = c.name === currentCase;
html += `
<button class="mobile-case-item ${isSelected ? 'selected' : ''}"
data-search="${escapeHtml(`${c.label} ${c.name}`.toLowerCase())}"
onclick="app.selectMobileCase(${escapeHtml(JSON.stringify(c.name))})">
<span class="mobile-case-item-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -4528,7 +4529,61 @@ Object.assign(CodemanApp.prototype, {
}
listContainer.innerHTML = html;
// Every open starts unfiltered. The search box is not focused on purpose:
// that would raise the phone keyboard over a list most opens just tap.
const search = document.getElementById('mobileCaseSearch');
if (search) search.value = '';
listContainer.parentElement.style.minHeight = '';
this.filterMobileCases();
modal.classList.add('active');
// Bring the current case into view when the list is longer than the sheet.
// Scroll the list's own box, never scrollIntoView(), which can also scroll
// the document under the fixed header.
const body = listContainer.parentElement;
const selected = listContainer.querySelector('.mobile-case-item.selected');
if (body && selected) {
const top = selected.offsetTop - body.offsetTop;
if (top + selected.offsetHeight > body.scrollTop + body.clientHeight) {
body.scrollTop = top - (body.clientHeight - selected.offsetHeight) / 2;
}
}
},
/** Hide case rows whose name does not contain every word typed in the search box. */
filterMobileCases() {
const search = document.getElementById('mobileCaseSearch');
const words = (search?.value || '').toLowerCase().split(/\s+/).filter(Boolean);
// Hold the list at its unfiltered height while searching, so the sheet (and
// the input under the thumb) does not jump as rows disappear.
const body = document.querySelector('.mobile-case-picker-body');
if (body && words.length && !body.style.minHeight) body.style.minHeight = `${body.offsetHeight}px`;
let shown = 0;
for (const item of document.querySelectorAll('#mobileCaseList .mobile-case-item')) {
const hay = item.dataset.search || '';
const match = words.every((w) => hay.includes(w));
item.hidden = !match;
if (match) shown++;
}
const empty = document.getElementById('mobileCaseEmpty');
if (empty) empty.hidden = shown > 0;
},
/** Enter picks the case when the search narrows the list to exactly one; Escape clears, then closes. */
onMobileCaseSearchKey(event) {
if (event.key === 'Enter') {
event.preventDefault();
const visible = [...document.querySelectorAll('#mobileCaseList .mobile-case-item:not([hidden])')];
if (visible.length === 1) visible[0].click();
} else if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
if (event.target.value) {
event.target.value = '';
this.filterMobileCases();
} else {
this.closeMobileCasePicker();
}
}
},
closeMobileCasePicker() {
+50 -1
View File
@@ -7072,12 +7072,61 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
color: #fff;
}
.mobile-case-picker-search {
display: flex;
align-items: center;
gap: 8px;
margin: 12px 20px 4px;
padding: 0 12px;
background: var(--bg-input);
border: 1px solid var(--border-light);
border-radius: 8px;
color: var(--text-dim);
}
.mobile-case-picker-search:focus-within {
border-color: var(--accent, #22c55e);
}
.mobile-case-picker-search input {
flex: 1;
min-width: 0;
padding: 10px 0;
background: transparent;
border: none;
outline: none;
color: var(--text);
/* 16px keeps iOS Safari from zooming the page on focus */
font-size: 16px;
}
/* The wrapper draws the focus state; the global input focus ring doubled it */
#mobileCaseSearch:focus {
outline: none;
border: none;
box-shadow: none;
}
.mobile-case-empty {
padding: 20px;
text-align: center;
color: var(--text-dim);
font-size: 0.9rem;
}
.mobile-case-empty[hidden],
.mobile-case-item[hidden] {
display: none;
}
.mobile-case-picker-body {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 8px 0;
max-height: 50vh;
/* Fill the sheet (its max-height is the cap); a separate cap here left the
list shorter than the sheet could show. min-height lets flex shrink it. */
min-height: 0;
}
.mobile-case-list {
+10 -1
View File
@@ -175,7 +175,16 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
try {
const msg = JSON.parse(String(raw));
if (msg.t === 'i' && typeof msg.d === 'string') {
if (msg.d.length > MAX_INPUT_LENGTH) return;
if (msg.d.length > MAX_INPUT_LENGTH) {
// Refused for good, so say so: a silent return left the frame
// unACKed and the client redelivered it every few seconds forever
// (issue #484). A client that predates `err` reads this as a plain
// ACK and drops the frame, which is also the right outcome.
if (Number.isInteger(msg.seq) && socket.readyState === 1) {
socket.send(`{"t":"ia","seq":${msg.seq as number},"err":"too_large","max":${MAX_INPUT_LENGTH}}`);
}
return;
}
// Reliable delivery: when the frame carries a clientId + seq, apply it
// exactly once (skip a duplicate redelivery) but ACK it regardless so
// the client can drop it from its durable queue. Frames without seq
+5 -1
View File
@@ -20,6 +20,7 @@ import {
import { MAX_EDITABLE_BYTES } from '../config/file-editing.js';
import { MIN_MATCH_LENGTH, MAX_MATCH_LENGTH } from '../config/agent-wait.js';
import { MAX_WAKE_MACS } from '../config/remote-wake-limits.js';
import { MAX_INPUT_LENGTH } from '../config/terminal-limits.js';
import { enabledCliIds, enabledClis } from '../config/cli-registry/registry.js';
import type { SessionMode } from '../types.js';
@@ -1500,7 +1501,10 @@ export const SettingsUpdateSchema = z
* Schema for POST /api/sessions/:id/input with length limit
*/
export const SessionInputWithLimitSchema = z.object({
input: z.string().max(100000), // 100KB max input
// One limit for both transports (issue #484): the route's own length check and
// ws-routes.ts read the same constant, so a schema cap above it only hid which
// check refused the input.
input: z.string().max(MAX_INPUT_LENGTH),
useMux: z.boolean().optional(),
// Reliable-delivery dedup (optional; absent for curl/legacy clients). The web
// client tags each input with a stable clientId + a monotonic per-session seq
+108
View File
@@ -0,0 +1,108 @@
/**
* @fileoverview Oversized input must never poison the durable input queue (#484).
*
* The failure: a paste over MAX_INPUT_LENGTH was queued for reliable delivery,
* refused by both transports (the WebSocket silently, the POST with a 400), and
* never dropped by the client, which treated a 400 like a transient failure. It
* was re-sent every 2 s forever, blocked every later input for that session
* behind it, and came back from localStorage on every reload.
*
* The fix, pinned here: the client splits a large paste into in-limit frames
* (one limit, shared with the server), drops a frame the server refused for
* good, and prunes oversized frames persisted by an older build.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
import { MAX_INPUT_LENGTH } from '../src/config/terminal-limits.js';
import { SessionInputWithLimitSchema } from '../src/web/schemas.js';
const pub = (f: string) => readFileSync(resolve(import.meta.dirname, '../src/web/public', f), 'utf8');
const appSource = pub('app.js');
type InputLimit = { FRAME_MAX_CHARS: number; PASTE_MAX_CHARS: number; split: (d: string, max?: number) => string[] };
function loadLimit(): InputLimit {
const context = vm.createContext({ window: {}, globalThis: {} });
vm.runInContext(pub('constants.js'), context, { filename: 'constants.js' });
return (context.window as { CodemanInputLimit: InputLimit }).CodemanInputLimit;
}
describe('one input limit on both sides', () => {
it('the frontend frame limit equals the server MAX_INPUT_LENGTH', () => {
expect(loadLimit().FRAME_MAX_CHARS).toBe(MAX_INPUT_LENGTH);
});
it('the composer budget is derived from the same number', () => {
expect(pub('keyboard-accessory.js')).toMatch(
new RegExp(`COMPOSER_INPUT_FRAME_LIMIT = (64 \\* 1024|${MAX_INPUT_LENGTH});`)
);
expect(MAX_INPUT_LENGTH).toBe(64 * 1024);
});
it('the POST schema caps input at MAX_INPUT_LENGTH, not a second number', () => {
expect(SessionInputWithLimitSchema.safeParse({ input: 'x'.repeat(MAX_INPUT_LENGTH) }).success).toBe(true);
expect(SessionInputWithLimitSchema.safeParse({ input: 'x'.repeat(MAX_INPUT_LENGTH + 1) }).success).toBe(false);
});
});
describe('splitInputFrames', () => {
const { split } = loadLimit();
it('returns a short input as one frame and nothing for empty input', () => {
expect(split('abc')).toEqual(['abc']);
expect(split('')).toEqual([]);
});
it('splits a large paste into in-limit frames that rejoin byte-identically', () => {
const paste = '\x1b[200~' + 'log line\n'.repeat(15000) + '\x1b[201~';
const frames = split(paste);
expect(frames.length).toBeGreaterThan(1);
for (const f of frames) expect(f.length).toBeLessThanOrEqual(MAX_INPUT_LENGTH);
expect(frames.join('')).toBe(paste);
});
it('never cuts a surrogate pair in half', () => {
const paste = 'a' + '😀'.repeat(10); // pairs start at odd offsets
const frames = split(paste, 4);
expect(frames.join('')).toBe(paste);
for (const f of frames) {
const last = f.charCodeAt(f.length - 1);
expect(last >= 0xd800 && last <= 0xdbff).toBe(false);
const first = f.charCodeAt(0);
expect(first >= 0xdc00 && first <= 0xdfff).toBe(false);
}
});
});
describe('the client never queues or keeps an undeliverable frame', () => {
const sendAsync = appSource.slice(
appSource.indexOf('_sendInputAsync(sessionId, input, opts) {'),
appSource.indexOf('_sendInputEphemeral(sessionId, input) {')
);
it('splits an oversized paste, and refuses an oversized mux write or giant paste with a toast', () => {
expect(sendAsync).toContain('limit.split(input)');
expect(sendAsync).toMatch(/useMux \|\| input\.length > limit\.PASTE_MAX_CHARS/);
expect(sendAsync).toContain('showToast');
});
it('drops a POST the server refused as invalid instead of retrying it forever', () => {
const drain = appSource.slice(appSource.indexOf('_drainSession(sessionId) {'));
const body = drain.slice(0, drain.indexOf('_dropRejectedInput(sessionId, rec) {'));
expect(body).toMatch(
/resp\.status === 400 \|\| resp\.status === 413\)\) \{\s*[\s\S]{0,400}this\._dropRejectedInput\(sessionId, rec\);/
);
});
it('drops a frame the WebSocket refused with an error ACK', () => {
const handler = appSource.slice(appSource.indexOf('_onWsInputAck(seq, msg) {'));
expect(handler.slice(0, 600)).toMatch(/if \(msg && msg\.err\)/);
});
it('prunes oversized frames persisted by an older build on load', () => {
const load = appSource.slice(appSource.indexOf('_loadReliableState() {'));
expect(load.slice(0, 3000)).toMatch(/r\.data\.length <= frameMax/);
});
});
+16
View File
@@ -282,6 +282,22 @@ describe('ws-routes', () => {
}
});
it('refuses an oversized sequenced frame with an error ACK, so the client can drop it', async () => {
// Issue #484: a silent return left the frame unACKed, and the client's
// durable queue re-sent it every few seconds forever.
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
try {
const session = ctx._session;
const hugeInput = 'y'.repeat(MAX_INPUT_LENGTH + 1);
ws.send(JSON.stringify({ t: 'i', d: hugeInput, cid: 'c1', seq: 3 }));
expect(await nextMessage(ws)).toEqual({ t: 'ia', seq: 3, err: 'too_large', max: MAX_INPUT_LENGTH });
expect(session.writeBuffer).not.toContain(hugeInput);
} finally {
ws.close();
}
});
it('ignores malformed JSON messages', async () => {
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
try {