mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 01:09:43 +02:00
No behavior change. Prepares the split pane's second terminal (and later grid tiles) to share what today only the primary terminal has: - _inputSocketFor/_registerInputSocket/_unregisterInputSocket: the exactly-once input queue, its ACK handling and the redelivery sweep now deliver over any registered socket bound to a session, not only this._ws. ACKs are routed by the receiving socket's session; silence is judged per socket; a stale handle cannot unregister its replacement. - registerFilePathLinkProvider, cleanedTerminalSelection, copyTerminalSelection and _handleImagePaste take an optional target terminal and session (defaults: the primary pane). - _focusedPane() is the one place to ask which pane the keyboard is in (primary only, for now); _forEachTile() replaces the _splitPane special cases in the font, family, weight, skin and resize paths. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
109 lines
4.7 KiB
TypeScript
109 lines
4.7 KiB
TypeScript
/**
|
|
* @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, sessionId = this._wsSessionId) {'));
|
|
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/);
|
|
});
|
|
});
|