From 4956d9e7e6afa5a5c756ca1b8615dc342353dc97 Mon Sep 17 00:00:00 2001 From: Jake Savin Date: Mon, 29 Jun 2026 21:06:45 -0700 Subject: [PATCH 1/2] feat(boxen): C M4 #812 -- input_decoder bracketed paste + BOXEN_EV_PASTE Implements milestone M4 of the Phase C input decoder (planning/phase_c/ INPUT_DECODER_PLAN.md sections 3.8, 5.3, 4 (PASTE_ACTIVE state), 7 (M4 spec)). ABI change (contained to this milestone): - boxen.h: add BOXEN_EV_PASTE event type and `paste` union arm (heap-allocated `data` + `len`; consumer owns the free). Decoder: - input_decoder.c: PASTE_ACTIVE state with streaming close-marker matcher (\x1b[201~). Paste buffer grows geometrically from 256 bytes up to the 256 KiB cap; oversize pastes truncate and emit a single BOXEN_LOG_W per paste. ESC bytes inside the paste body are treated as literal content (never re-parsed as CSI). CR/LF normalization at emit time: CR / CRLF / LF all collapse to LF in the emitted data. - Streaming close-match (independent of paste_buf) ensures pastes exceeding the cap still terminate cleanly when the close marker arrives -- otherwise the decoder would wedge in PASTE_ACTIVE forever. REPL: - boxen_repl_run_one_tick: handle BOXEN_EV_PASTE by inserting ev.paste.data at the input bar cursor, mirroring the typed-char insertion path (memmove tail + memcpy + advance cursor). Truncates at BOXEN_REPL_INPUT_MAX-1 with the same convention as typed chars. Cancels pending slash-palette debounce and history navigation; frees ev.paste.data unconditionally (consumer ownership per boxen.h). Tests: - tests/input_decoder_tests.c: 6 new behavioral tests (simple paste, CR/LF normalization, 256 KiB truncation + BOXEN_LOG_W, embedded ESC literal, empty paste, split-across-inject). Net 127 tests pass (was 121 + 4 SKIP stubs M4 -> 6 behavioral + 4 SKIP stubs M5). - tests/boxen_repl_tests.c: 5 new dispatch tests (empty input, mid-buffer insert, oversize truncation, empty paste no-op, palette debounce cancellation). Net 51 tests pass (was 46). - tests/Makefile: link boxen_log.c into input_decoder_tests so BOXEN_LOG_W resolves and tests can install boxen_set_log_hook to observe the truncation warning. Verification: - Unit suite: 953/953 passed (was 946 + 11 new - 4 SKIP-stub removals). - Integration suite: 21 failures, matches the pre-existing baseline. All failing tests are unrelated to M4 (html, tcp, startup). - Manual smoke deferred to M6 cutover (decoder not yet wired into backend_tb2.c). Closes #812 --- frontier-cli/boxen/boxen.h | 33 +++ frontier-cli/boxen/input_decoder.c | 335 ++++++++++++++++++++++++++++- frontier-cli/boxen_repl.c | 72 +++++++ tests/Makefile | 7 +- tests/boxen_repl_tests.c | 150 +++++++++++++ tests/input_decoder_tests.c | 284 ++++++++++++++++++++++-- 6 files changed, 855 insertions(+), 26 deletions(-) diff --git a/frontier-cli/boxen/boxen.h b/frontier-cli/boxen/boxen.h index 13951b2de..95d918fc0 100644 --- a/frontier-cli/boxen/boxen.h +++ b/frontier-cli/boxen/boxen.h @@ -141,6 +141,19 @@ typedef enum { BOXEN_EV_KEY, BOXEN_EV_MOUSE, BOXEN_EV_RESIZE, + /* 2026-06-29 JES #812 Phase C M4: bracketed-paste event. + * + * Emitted by the input decoder when a paste sequence (\e[200~ ... \e[201~) + * is fully buffered. The data field is a heap-allocated UTF-8 byte + * string (NOT NUL-terminated -- len is authoritative); the consumer of + * the event takes ownership and MUST free(ev.paste.data) after handling + * the event. This is the only event variant whose union arm transfers + * heap ownership to the caller; all other event variants are POD. + * + * See planning/phase_c/INPUT_DECODER_PLAN.md sections 3.8 and 5.3 for + * the rationale (Cmd-V into the REPL under mouse mode must not be + * shredded into per-keystroke events). */ + BOXEN_EV_PASTE, } boxen_event_type_t; typedef struct boxen_event { @@ -161,6 +174,26 @@ typedef struct boxen_event { struct { int w, h; } resize; + /* 2026-06-29 JES #812 Phase C M4: bracketed-paste payload. + * + * data: heap-allocated, owned by the event consumer. NOT NUL- + * terminated -- len is the authoritative byte count. CR/LF + * line endings inside the paste are normalized to LF (\n) by + * the decoder; embedded NULs are passed through unchanged + * (callers that treat the buffer as a C string must filter). + * len: byte length of data (0 if data is NULL). + * + * If len == 0 and data == NULL the event represents an empty paste + * (the user pasted nothing between the markers). Consumers should + * still call free(data) defensively -- free(NULL) is a no-op. + * + * The decoder caps paste size at 256 KiB; oversize pastes are + * truncated to the cap and a BOXEN_LOG_W is emitted at the + * truncation point. */ + struct { + char *data; + size_t len; + } paste; }; } boxen_event_t; diff --git a/frontier-cli/boxen/input_decoder.c b/frontier-cli/boxen/input_decoder.c index 9e6f490ae..0997f0080 100644 --- a/frontier-cli/boxen/input_decoder.c +++ b/frontier-cli/boxen/input_decoder.c @@ -7,6 +7,9 @@ * events from the buffered byte stream. * 2026-06-29 JES #811 M3: SGR mouse parsing, X10 fallback, double-click * synthesis, burst-read contract. + * 2026-06-29 JES #812 M4: bracketed-paste handling (PASTE_ACTIVE state + + * heap-grown paste buffer + BOXEN_EV_PASTE emission with CR/LF + * normalization, 256 KiB size cap, and BOXEN_LOG_W on truncation). * * Scope of this file at M2: * - Allocate / free the input_decoder_t struct. @@ -67,6 +70,35 @@ * masked-index arithmetic if we move from memmove-on-drain to a true ring. */ #define INPUT_DECODER_BUFFER_SIZE 4096 +/* 2026-06-29 JES #812 M4: bracketed-paste sizing. + * + * INPUT_DECODER_PASTE_CAP is the hard ceiling on a single paste's accepted + * byte count. Past this size we keep PARSING the paste body (so the + * \e[201~ marker still terminates cleanly and we return to GROUND) but + * STOP appending bytes to the heap buffer. The cap fires once per paste; + * BOXEN_LOG_W is emitted the first time we drop a byte on this path so + * the operator sees a single deterministic warning per truncated paste. + * + * INPUT_DECODER_PASTE_INITIAL is the first heap allocation size. Reads + * grow geometrically (doubling) up to the cap; the initial size matters + * only for short pastes (the common case -- a clipboard snippet is + * typically <1 KiB) so we don't waste pages on a single keypress-worth + * of data. 256 bytes covers most identifier / URL pastes without a + * realloc; longer pastes pay one or two realloc costs to reach 256 KiB. + * + * Plan reference: section 3.8 (size limits + truncation contract), + * section 7 M4 (deliverable scope), risk R5 (size-cap overflow). */ +#define INPUT_DECODER_PASTE_CAP (256 * 1024) +#define INPUT_DECODER_PASTE_INITIAL 256 + +/* 2026-06-29 JES #812 M4: bracketed-paste close-marker length. + * + * The closing sequence "\x1b[201~" is 6 bytes. We scan a 6-byte suffix + * window of the paste buffer after each appended byte to detect the + * close marker. Keeping the constant near the buffer cap and the + * append site makes the suffix-scan invariant easy to audit. */ +#define INPUT_DECODER_PASTE_CLOSE_LEN 6 + /* 2026-06-30 JES #810 M2: CSI parameter buffer. * * A modifier-rich CSI sequence holds at most ~3 semicolon-separated params @@ -125,6 +157,17 @@ typedef enum { * GROUND on overflow, which let `\e[ <65+ digits> A` inject the trailing * digits into the input stream after the cap was hit). */ DEC_STATE_CSI_SWALLOW, + /* 2026-06-29 JES #812 M4: inside a bracketed paste. + * + * Entered when decode_csi sees \e[200~; left when the trailing + * "\x1b[201~" byte sequence is observed in the paste content. While + * in this state EVERY byte (including ESC and CSI introducers) is + * treated as literal paste content -- the state machine NEVER falls + * back into CSI parsing for bytes that arrive during a paste, even + * if they would be valid escape sequences in GROUND. This is the + * point of bracketed paste: a Cmd-V containing "\e[A" must appear as + * literal text in the paste buffer, not as a KEY_UP event. */ + DEC_STATE_PASTE_ACTIVE, } dec_state_t; struct input_decoder { @@ -237,6 +280,46 @@ struct input_decoder { * 3 raw payload bytes from the ring before emitting the event. This flag * suspends normal state-machine processing until the bytes arrive. */ bool x10_pending; + + /* 2026-06-29 JES #812 M4: bracketed-paste accumulator. + * + * paste_buf is heap-allocated lazily on entry to PASTE_ACTIVE (when + * decode_csi sees \e[200~). paste_len tracks the number of bytes + * appended so far. paste_cap tracks the current allocated capacity, + * grown geometrically up to INPUT_DECODER_PASTE_CAP. paste_truncated + * latches true the first time we drop a byte (one BOXEN_LOG_W per + * paste, not one per dropped byte). + * + * paste_close_match is the count of consecutive bytes of the + * "\x1b[201~" close marker matched so far. Tracked independently of + * the paste buffer because, after the truncation cap is reached, we + * STOP appending to paste_buf but must KEEP scanning the byte stream + * for the close marker -- otherwise a paste larger than the cap + * never terminates and the decoder stays wedged in PASTE_ACTIVE + * forever. On a partial mismatch (e.g., "\x1b[20" followed by 'x'), + * the previously matched bytes are committed to paste_buf and the + * matcher resets (see flush_partial_close_match in poll). + * + * On a successful close (\x1b[201~ seen), ownership of paste_buf + * transfers to the emitted BOXEN_EV_PASTE event (ev.paste.data); the + * decoder zeroes its own pointer and the caller owns the free(). On + * input_decoder_destroy with an in-progress paste, paste_buf is freed + * to prevent a leak. See plan section 3.8 + risk R7 (caller-free + * contract). + * + * Memory model: paste_buf can be NULL if the paste body is empty (no + * bytes between markers); in that case the emitted event has + * data=NULL, len=0. Otherwise paste_buf is a single contiguous + * malloc'd region of paste_cap bytes, of which the first paste_len + * are populated. + * + * Single-owner / GIL invariant from input_decoder.h applies; no + * locking. */ + char *paste_buf; + size_t paste_len; + size_t paste_cap; + bool paste_truncated; + uint8_t paste_close_match; /* 0..INPUT_DECODER_PASTE_CLOSE_LEN */ }; /* ------------------------------------------------------------------------- @@ -262,6 +345,14 @@ input_decoder_t *input_decoder_create(int tty_fd) { dec->utf8_remaining = 0; dec->utf8_codepoint = 0; dec->parse_errors = 0; + /* 2026-06-29 JES #812 M4: paste buffer is lazy-allocated on entry to + * PASTE_ACTIVE. All fields zeroed by calloc above; explicit reset + * here documents the post-condition. */ + dec->paste_buf = NULL; + dec->paste_len = 0; + dec->paste_cap = 0; + dec->paste_truncated = false; + dec->paste_close_match = 0; return dec; } @@ -269,9 +360,14 @@ void input_decoder_destroy(input_decoder_t *dec) { if (dec == NULL) { return; } - /* M1: no resources beyond the struct itself. M3 / M4 will own paste - * buffers and possibly an escape-write retry queue; both will be freed - * here. */ + /* 2026-06-29 JES #812 M4: free the in-progress paste buffer if any. + * The buffer is normally consumed by the BOXEN_EV_PASTE emission + * (ownership transfers to the caller, who frees ev.paste.data); if + * the decoder is destroyed mid-paste -- e.g., shutdown during a + * runaway paste burst, or a test that exits early -- this prevents a + * leak. free(NULL) is a no-op, so this is safe whether or not a + * paste was active. */ + free(dec->paste_buf); free(dec); } @@ -753,6 +849,77 @@ static bool decode_sgr_mouse(input_decoder_t *dec, const uint8_t *csi_buf, return true; } +/* 2026-06-29 JES #812 M4: paste-buffer helpers. + * + * paste_buf_append: append a single content byte to dec->paste_buf, growing + * the heap allocation geometrically (doubling) up to INPUT_DECODER_PASTE_CAP. + * Past the cap, byte is dropped and paste_truncated is latched (one + * BOXEN_LOG_W per paste). Returns nothing -- success vs. truncation is + * reflected only in dec->paste_truncated; the caller continues parsing + * the rest of the paste regardless. + * + * paste_buf_reset: zero the paste accumulator fields without freeing. Used + * after the BOXEN_EV_PASTE event takes ownership of the buffer (so we + * don't double-free). + * + * Growth strategy: 256 -> 512 -> 1024 -> ... -> 256 KiB. Realloc on each + * doubling; ~10 reallocs total to reach the cap. Cheap; not the hot path. + * The previous content survives realloc by definition. + * + * On allocation failure inside paste_buf_append we latch paste_truncated + * and stop growing (we keep whatever bytes we already buffered). This + * matches the "truncate + warn" contract: a paste that hits ENOMEM + * mid-flight still emits the partial event so the user sees something + * rather than nothing. */ +static void paste_buf_append(input_decoder_t *dec, uint8_t b) { + if (dec->paste_len >= INPUT_DECODER_PASTE_CAP) { + if (!dec->paste_truncated) { + dec->paste_truncated = true; + BOXEN_LOG_W("bracketed paste exceeds %zu-byte cap; truncated", + (size_t)INPUT_DECODER_PASTE_CAP); + } + return; + } + if (dec->paste_len >= dec->paste_cap) { + size_t new_cap = dec->paste_cap == 0 + ? (size_t)INPUT_DECODER_PASTE_INITIAL + : dec->paste_cap * 2; + if (new_cap > (size_t)INPUT_DECODER_PASTE_CAP) { + new_cap = (size_t)INPUT_DECODER_PASTE_CAP; + } + char *grown = (char *)realloc(dec->paste_buf, new_cap); + if (grown == NULL) { + /* OOM: keep what we have, latch truncation, stop appending. */ + if (!dec->paste_truncated) { + dec->paste_truncated = true; + BOXEN_LOG_W("bracketed paste realloc failed at %zu bytes; " + "truncated", dec->paste_len); + } + return; + } + dec->paste_buf = grown; + dec->paste_cap = new_cap; + } + dec->paste_buf[dec->paste_len++] = (char)b; +} + +static void paste_buf_reset(input_decoder_t *dec) { + dec->paste_buf = NULL; + dec->paste_len = 0; + dec->paste_cap = 0; + dec->paste_truncated = false; + dec->paste_close_match = 0; +} + +/* 2026-06-29 JES #812 M4: close-marker bytes. + * + * The terminating sequence "\x1b[201~" (6 bytes). Stored as a file-scope + * constant so the streaming matcher and a future audit reader share one + * source of truth. */ +static const uint8_t PASTE_CLOSE_MARKER[INPUT_DECODER_PASTE_CLOSE_LEN] = { + 0x1B, '[', '2', '0', '1', '~' +}; + /* 2026-06-30 JES #810 M2: dispatch a complete CSI sequence to a boxen_event. * 2026-06-29 JES #811 M3: added dec param for SGR mouse / double-click state; * added 'M' (SGR press / X10 indicator) and 'm' (SGR release) final bytes. @@ -865,7 +1032,24 @@ static bool decode_csi(input_decoder_t *dec, const uint8_t *csi_buf, case 21: emit_key(out, BOXEN_KEY_F10, 0, tilde_mod); return true; case 23: emit_key(out, BOXEN_KEY_F11, 0, tilde_mod); return true; case 24: emit_key(out, BOXEN_KEY_F12, 0, tilde_mod); return true; - /* params[0] == 200 / 201 are bracketed paste markers (M4). */ + case 200: + /* 2026-06-29 JES #812 M4: bracketed paste opens. Enter + * PASTE_ACTIVE; the poll loop's per-state branch buffers + * subsequent bytes into dec->paste_buf until the close + * marker (\x1b[201~) is seen. Note: we do NOT emit an + * event here -- the open marker is silent; only the + * complete paste (on close) produces BOXEN_EV_PASTE. + * Return false (no event yet) so poll continues. */ + dec->state = DEC_STATE_PASTE_ACTIVE; + paste_buf_reset(dec); + return false; + case 201: + /* 2026-06-29 JES #812 M4: spurious close marker outside a + * paste body. Ignore -- a stray \e[201~ from a buggy + * terminal or a mid-stream desync must not break the input + * stream. Returning false flows into the poll loop's + * "no event" branch which bumps parse_errors. */ + return false; default: return false; } } @@ -1054,6 +1238,116 @@ int input_decoder_poll(input_decoder_t *dec, boxen_event_t *out, int timeout_ms) memset(out, 0, sizeof(*out)); while (dec->head < dec->fill_len) { + /* 2026-06-29 JES #812 M4: PASTE_ACTIVE branch. + * + * Inside a paste, EVERY byte is literal content -- ESC, [, ~, + * UTF-8 leads, anything. We stream-match the close marker + * (\x1b[201~) byte-by-byte against the incoming byte. Bytes + * that EXTEND the partial close match are buffered ONLY when + * the match completes (close detected) or aborts (mismatch -> + * those bytes are part of the paste body and get flushed to + * paste_buf). + * + * Independent close-match tracking is load-bearing: once the + * paste exceeds INPUT_DECODER_PASTE_CAP we stop appending to + * paste_buf but MUST keep scanning for the close marker -- + * otherwise a paste bigger than the cap wedges the decoder in + * PASTE_ACTIVE forever. See paste_close_match doc on the + * struct. + * + * Burst safety: this branch consumes one byte per iteration and + * returns to the top of the while loop on every byte, so a + * megabyte-sized paste burst polls forward one byte at a time. + * That's fine for the M2/M3 byte-at-a-time event cadence; the + * full paste emits as one event regardless. */ + if (dec->state == DEC_STATE_PASTE_ACTIVE) { + uint8_t b = dec->buf[dec->head]; + dec->head++; + + if (b == PASTE_CLOSE_MARKER[dec->paste_close_match]) { + /* Byte extends the partial close match. Hold off on + * buffering -- if the full marker arrives we trim it + * out of the paste body. */ + dec->paste_close_match++; + if (dec->paste_close_match < + (uint8_t)INPUT_DECODER_PASTE_CLOSE_LEN) { + continue; + } + /* Full close match -- fall through to emit. */ + } else { + /* Mismatch. Flush the previously matched bytes to + * paste_buf (they were literal content after all), + * then handle the current byte. + * + * Edge case: the current byte may itself be the start + * of a NEW partial close match (e.g., paste body + * contains "\x1b[201X\x1b[201~" -- the X aborts the + * first match attempt, then a new ESC starts a fresh + * one). Handle this by re-checking the current byte + * against marker[0] after flushing the partial match. */ + for (uint8_t i = 0; i < dec->paste_close_match; i++) { + paste_buf_append(dec, PASTE_CLOSE_MARKER[i]); + } + dec->paste_close_match = 0; + + if (b == PASTE_CLOSE_MARKER[0]) { + dec->paste_close_match = 1; + } else { + paste_buf_append(dec, b); + } + continue; + } + + /* Close detected. paste_close_match == close_len; the + * matched bytes were held back so paste_buf does NOT need + * trimming. Just reset the matcher and emit. */ + dec->paste_close_match = 0; + + /* CR/LF normalization in place. + * + * Two-pointer scan: write_pos always <= read_pos so the + * compaction is safe. Rules: + * - "\r\n" collapses to "\n" + * - lone "\r" becomes "\n" + * - "\n" passes through unchanged + * Result: every line break is a single LF regardless of how + * the user's clipboard or source platform encoded it. */ + size_t w = 0; + for (size_t r = 0; r < dec->paste_len; r++) { + char c = dec->paste_buf[r]; + if (c == '\r') { + dec->paste_buf[w++] = '\n'; + /* If the next byte is LF, skip it (CRLF -> LF). */ + if (r + 1 < dec->paste_len && + dec->paste_buf[r + 1] == '\n') { + r++; + } + } else { + dec->paste_buf[w++] = c; + } + } + dec->paste_len = w; + + /* Emit BOXEN_EV_PASTE. Ownership of paste_buf transfers to + * the caller (ev.paste.data); decoder zeroes its own + * pointer so destroy doesn't double-free. + * + * Empty paste: data may be a real allocation of paste_cap + * bytes with len==0, or NULL (no append ever happened). + * Either is acceptable per the boxen.h contract; we hand + * back whatever we have and the consumer's free() handles + * both cases. */ + memset(out, 0, sizeof(*out)); + out->type = BOXEN_EV_PASTE; + out->paste.data = dec->paste_buf; + out->paste.len = dec->paste_len; + + paste_buf_reset(dec); + dec->state = DEC_STATE_GROUND; + compact_buffer(dec); + return BOXEN_OK; + } + /* 2026-06-29 JES #811 M3: X10 mouse raw-payload consumer. * * After \e[M is seen (csi_buf empty, final='M'), decode_csi sets @@ -1236,11 +1530,23 @@ int input_decoder_poll(input_decoder_t *dec, boxen_event_t *out, int timeout_ms) continue; } if (b >= 0x40 && b <= 0x7E) { - /* Final byte: decode. */ + /* Final byte: decode. + * + * 2026-06-29 JES #812 M4: decode_csi may mutate dec->state + * (entering DEC_STATE_PASTE_ACTIVE on \e[200~). Therefore + * the post-decode state reset to GROUND must be conditional: + * leave the state alone if decode_csi transitioned us to a + * non-GROUND target. Without this check the paste-open + * transition would be silently reverted and the paste body + * bytes would re-enter the GROUND state machine instead of + * being buffered. csi_len reset is always safe -- the + * paste buffer is a different field. */ dec->head++; bool emitted = decode_csi(dec, dec->csi_buf, dec->csi_len, b, out); - dec->state = DEC_STATE_GROUND; dec->csi_len = 0; + if (dec->state == DEC_STATE_CSI_COLLECTING) { + dec->state = DEC_STATE_GROUND; + } if (emitted) { compact_buffer(dec); return BOXEN_OK; @@ -1251,6 +1557,14 @@ int input_decoder_poll(input_decoder_t *dec, boxen_event_t *out, int timeout_ms) if (dec->x10_pending) { continue; } + /* 2026-06-29 JES #812 M4: if decode_csi entered + * PASTE_ACTIVE (saw \e[200~), continue the loop so the + * paste-active branch at the top consumes subsequent + * bytes. Don't count as a parse error -- it's the + * paste open marker, by design. */ + if (dec->state == DEC_STATE_PASTE_ACTIVE) { + continue; + } /* Unknown final: discard silently, keep parsing. */ dec->parse_errors++; continue; @@ -1337,6 +1651,15 @@ int input_decoder_poll(input_decoder_t *dec, boxen_event_t *out, int timeout_ms) /* Still need more continuation bytes. */ continue; } + + case DEC_STATE_PASTE_ACTIVE: + /* 2026-06-29 JES #812 M4: handled by the early-return branch + * at the top of the while loop. Unreachable here; assert + * the invariant so a future refactor that drops the early + * branch fails loudly instead of silently mis-parsing paste + * bytes through the GROUND state machine. */ + assert(false && "PASTE_ACTIVE handled before state switch"); + return BOXEN_ERR_IO; } } diff --git a/frontier-cli/boxen_repl.c b/frontier-cli/boxen_repl.c index b2d451f76..fd0a3e148 100644 --- a/frontier-cli/boxen_repl.c +++ b/frontier-cli/boxen_repl.c @@ -1545,6 +1545,78 @@ int boxen_repl_run_one_tick(boxen_repl_state_t *s, const boxen_event_t *ev) { return REPL_CONTINUE; } + /* 2026-06-29 JES #812 Phase C M4: bracketed-paste handler. + * + * The input decoder emits BOXEN_EV_PASTE when a paste sequence + * (\e[200~ ... \e[201~) completes. ev.paste.data is a heap-allocated + * UTF-8 byte block with CR/LF already normalized to LF by the + * decoder; we own it and must free() it. + * + * Insertion contract: drop the paste at the input bar's cursor + * position, shifting any tail bytes right. Bytes that would + * overflow BOXEN_REPL_INPUT_MAX are truncated -- the alternative + * (allocate a larger input_buf) would force a runtime malloc path + * for every paste; truncation at the current cap is consistent with + * the existing typed-char path (boxen_repl.c:1397) which already + * silently drops the char when input_buf is full. + * + * Newlines inside the paste are inserted as literal LF bytes; we do + * NOT auto-submit on embedded newlines. The user must press Enter + * explicitly to dispatch. Auto-submit is a future enhancement + * tracked under M6 cutover discussion -- some users want + * multi-statement paste-and-run, others want paste-and-edit-then- + * submit. Keeping the current behavior aligned with typed-char + * input (no auto-submit) is the safer default. + * + * If a slash-palette debounce is pending, cancel it: a paste means + * the user is committing to filling the input line, not opening the + * palette. Same rationale as the printable-char path's + * history_nav_idx reset above. */ + if (ev->type == BOXEN_EV_PASTE) { + if (s != NULL && ev->paste.data != NULL && ev->paste.len > 0 && + s->input_len < BOXEN_REPL_INPUT_MAX - 1) { + /* Cancel any pending slash-palette debounce. */ + if (s->slash_pending_until_ms != 0) { + s->slash_pending_until_ms = 0; + } + /* If user was navigating history, abandon nav. */ + if (s->history_nav_idx != -1) { + s->history_nav_idx = -1; + s->history_saved_input[0] = '\0'; + } + + size_t available = (size_t)(BOXEN_REPL_INPUT_MAX - 1 - + s->input_len); + size_t to_insert = (ev->paste.len < available) + ? ev->paste.len : available; + + /* Shift the tail right by to_insert bytes to make room. */ + size_t tail_len = (size_t)(s->input_len - + s->input_cursor_pos); + if (tail_len > 0) { + memmove(s->input_buf + s->input_cursor_pos + to_insert, + s->input_buf + s->input_cursor_pos, + tail_len); + } + memcpy(s->input_buf + s->input_cursor_pos, + ev->paste.data, to_insert); + s->input_len += (int)to_insert; + s->input_cursor_pos += (int)to_insert; + s->input_buf[s->input_len] = '\0'; + + if (s->input_win != NULL) { + boxen_window_invalidate(s->input_win); + } + } + /* Free the decoder-allocated paste data regardless of whether + * we inserted anything. free(NULL) is a no-op, so the + * defensive guard is just on data!=NULL. Per the + * BOXEN_EV_PASTE contract on boxen.h, the event consumer + * (this branch) owns the data and must free it. */ + free(ev->paste.data); + return s != NULL && s->should_quit ? REPL_QUIT : REPL_CONTINUE; + } + boxen_dispatch_event(ev); /* 2026-06-17 JES #691 Phase C.0.7a: check slash debounce after every event. diff --git a/tests/Makefile b/tests/Makefile index 37a0a9c41..b5714cf15 100644 --- a/tests/Makefile +++ b/tests/Makefile @@ -644,11 +644,16 @@ boxen_outline_tests: boxen_outline_tests.c $(BOXEN_OUTLINE_TEST_SRCS) # INPUT_DECODER_TEST_SEAM define exposes input_decoder_inject_bytes() and # input_decoder_buffered_bytes() so the harness can drive bytes without a # real PTY. See planning/phase_c/INPUT_DECODER_PLAN.md section 6.1. -input_decoder_tests: input_decoder_tests.c $(BOXEN_DIR)/input_decoder.c $(BOXEN_DIR)/input_decoder.h +# +# 2026-06-29 JES #812 M4: link boxen_log.c so input_decoder.c's BOXEN_LOG_W +# (used on the paste-size-cap truncation path) resolves, and so the M4 +# tests can install boxen_set_log_hook() to observe the warning. +input_decoder_tests: input_decoder_tests.c $(BOXEN_DIR)/input_decoder.c $(BOXEN_DIR)/input_decoder.h $(BOXEN_DIR)/boxen_log.c $(CC) $(CFLAGS) \ -I$(BOXEN_DIR) \ -DINPUT_DECODER_TEST_SEAM \ input_decoder_tests.c $(BOXEN_DIR)/input_decoder.c \ + $(BOXEN_DIR)/boxen_log.c \ -o $@ $(LINK_LIBS) # Pure helper for arg-injection — no Frontier runtime / handle deps, so it diff --git a/tests/boxen_repl_tests.c b/tests/boxen_repl_tests.c index e7a8f9281..e005866a0 100644 --- a/tests/boxen_repl_tests.c +++ b/tests/boxen_repl_tests.c @@ -2141,6 +2141,149 @@ static void test_append_when_pinned_to_bottom_does_not_bump_offset(void) { teardown(); } +/* ------------------------------------------------------------------------- + * 2026-06-29 JES #812 Phase C M4: BOXEN_EV_PASTE dispatch tests. + * + * Build a BOXEN_EV_PASTE event with a heap-allocated payload and feed it + * through boxen_repl_run_one_tick. Assert the input bar's input_buf + * reflects the paste at the cursor position. The handler is required to + * free(ev.paste.data) regardless of insertion outcome, so the test does + * NOT free it -- a leak here would surface in a future ASan / leak-sanitizer + * run (the harness is built with -O0 -g and tests are short-lived, but the + * pattern matters for future tooling). + * ---------------------------------------------------------------------- */ + +/* Build a BOXEN_EV_PASTE event with strdup'd data so the handler can + * free() it without affecting test-side storage. */ +static boxen_event_t make_paste_event(const char *text) { + boxen_event_t ev; + memset(&ev, 0, sizeof(ev)); + ev.type = BOXEN_EV_PASTE; + size_t len = (text != NULL) ? strlen(text) : 0; + if (len == 0) { + ev.paste.data = NULL; + ev.paste.len = 0; + return ev; + } + char *buf = (char *)malloc(len); + assert(buf != NULL); + memcpy(buf, text, len); + ev.paste.data = buf; + ev.paste.len = len; + return ev; +} + +/* 2026-06-29 JES #812 M4: paste into empty input bar appends at the + * start (cursor at 0). */ +static void test_paste_into_empty_input_appends(void) { + setup(); + + boxen_event_t ev = make_paste_event("hello world"); + boxen_repl_run_one_tick(&g_state, &ev); + + assert(strcmp(g_state.input_buf, "hello world") == 0); + assert(g_state.input_len == 11); + assert(g_state.input_cursor_pos == 11); + + teardown(); +} + +/* 2026-06-29 JES #812 M4: paste inserts at cursor position when the + * cursor is mid-buffer. Pre-load "abXYZ", caret at 2; paste "12" -> + * "ab12XYZ", caret at 4. */ +static void test_paste_mid_buffer_inserts_at_cursor(void) { + setup(); + + strncpy(g_state.input_buf, "abXYZ", + sizeof(g_state.input_buf) - 1); + g_state.input_len = 5; + g_state.input_cursor_pos = 2; + + boxen_event_t ev = make_paste_event("12"); + boxen_repl_run_one_tick(&g_state, &ev); + + assert(strcmp(g_state.input_buf, "ab12XYZ") == 0); + assert(g_state.input_len == 7); + assert(g_state.input_cursor_pos == 4); + + teardown(); +} + +/* 2026-06-29 JES #812 M4: oversize paste truncates at the input cap. + * + * input_buf size is BOXEN_REPL_INPUT_MAX (1024). Paste a string longer + * than the cap and verify the bar fills to cap-1 (the NUL byte reserves + * the final slot, same convention as the typed-char path). No crash, no + * heap corruption. */ +static void test_paste_oversize_truncates_at_input_cap(void) { + setup(); + + /* Build a 2048-byte paste body. */ + char *big = (char *)malloc(2048); + assert(big != NULL); + memset(big, 'q', 2048); + big[2048 - 1] = 'q'; + + boxen_event_t ev; + memset(&ev, 0, sizeof(ev)); + ev.type = BOXEN_EV_PASTE; + ev.paste.data = big; + ev.paste.len = 2048; + + boxen_repl_run_one_tick(&g_state, &ev); + + /* Should fill to BOXEN_REPL_INPUT_MAX - 1 (reserve NUL). */ + assert(g_state.input_len == BOXEN_REPL_INPUT_MAX - 1); + assert(g_state.input_buf[g_state.input_len] == '\0'); + /* Every inserted byte is 'q'. */ + for (int i = 0; i < g_state.input_len; i++) { + assert(g_state.input_buf[i] == 'q'); + } + + teardown(); +} + +/* 2026-06-29 JES #812 M4: empty paste is a no-op. + * + * data=NULL, len=0 should not crash and should not modify the input + * buffer. Pre-load "abc" and assert it is unchanged after the paste. */ +static void test_paste_empty_is_noop(void) { + setup(); + + strncpy(g_state.input_buf, "abc", + sizeof(g_state.input_buf) - 1); + g_state.input_len = 3; + g_state.input_cursor_pos = 3; + + boxen_event_t ev = make_paste_event(""); + /* ev.paste.data is NULL via make_paste_event's empty-string branch. */ + boxen_repl_run_one_tick(&g_state, &ev); + + assert(strcmp(g_state.input_buf, "abc") == 0); + assert(g_state.input_len == 3); + assert(g_state.input_cursor_pos == 3); + + teardown(); +} + +/* 2026-06-29 JES #812 M4: paste cancels a pending slash-palette + * debounce. Simulate the user pressing '/' (which starts the debounce + * window), then pasting -- the palette must NOT open. */ +static void test_paste_cancels_pending_slash_palette(void) { + setup(); + + /* Mimic the state set by the '/' debounce path. */ + g_state.slash_pending_until_ms = 999999999ULL; + + boxen_event_t ev = make_paste_event("text"); + boxen_repl_run_one_tick(&g_state, &ev); + + assert(g_state.slash_pending_until_ms == 0); + assert(strcmp(g_state.input_buf, "text") == 0); + + teardown(); +} + /* ------------------------------------------------------------------------- * main * ---------------------------------------------------------------------- */ @@ -2196,6 +2339,13 @@ int main(void) { TR_RUN(test_append_when_ring_full_advances_offset_to_pin_content); TR_RUN(test_append_when_pinned_to_bottom_does_not_bump_offset); + /* 2026-06-29 JES #812 Phase C M4: bracketed paste dispatch. */ + TR_RUN(test_paste_into_empty_input_appends); + TR_RUN(test_paste_mid_buffer_inserts_at_cursor); + TR_RUN(test_paste_oversize_truncates_at_input_cap); + TR_RUN(test_paste_empty_is_noop); + TR_RUN(test_paste_cancels_pending_slash_palette); + TR_SUMMARY(); return TR_EXIT_CODE(); } diff --git a/tests/input_decoder_tests.c b/tests/input_decoder_tests.c index cbbcb51ed..0b06c76b6 100644 --- a/tests/input_decoder_tests.c +++ b/tests/input_decoder_tests.c @@ -1904,27 +1904,271 @@ static void test_x10_mouse_malformed_payload_under_min(void) { } /* ------------------------------------------------------------------------- - * M4 SKIP stubs -- bracketed paste + BOXEN_EV_PASTE. + * 2026-06-29 JES #812 Phase C M4: bracketed-paste behavioral tests. + * + * Plan reference: planning/phase_c/INPUT_DECODER_PLAN.md sections 3.8, 5.3, + * 4 (PASTE_ACTIVE state), 7 (M4 spec). + * + * Each test installs (or relies on calloc-zeroed) a log hook so we can + * observe BOXEN_LOG_W emission on the size-cap path. The hook state is + * test-local (static counters) and reset at the top of every test. * ---------------------------------------------------------------------- */ -static void test_skip_bracketed_paste_simple(void) { - tr_skip("M4: \\e[200~hello\\e[201~ -> BOXEN_EV_PASTE data=\"hello\" " - "(plan section 3.8); requires boxen.h ABI add for BOXEN_EV_PASTE"); +static int g_paste_warn_count = 0; +static char g_paste_last_log[512]; + +static void paste_log_hook(boxen_log_level_t level, const char *file, + int line, const char *msg, void *user_data) { + (void)file; (void)line; (void)user_data; + if (level == BOXEN_LOG_WARN) { + g_paste_warn_count++; + snprintf(g_paste_last_log, sizeof(g_paste_last_log), "%s", + msg ? msg : ""); + } +} + +static void paste_log_reset(void) { + g_paste_warn_count = 0; + g_paste_last_log[0] = '\0'; + boxen_set_log_hook(paste_log_hook, NULL); +} + +/* 2026-06-29 JES #812 M4: simple paste round-trip. + * + * \e[200~hello\e[201~ -> BOXEN_EV_PASTE with data="hello", len=5. + * The caller owns ev.paste.data and must free() it (the test does so + * explicitly even though the harness exits afterward; matches production + * contract). */ +static void test_bracketed_paste_simple(void) { + paste_log_reset(); + input_decoder_t *dec = input_decoder_create(-1); + assert(dec != NULL); + + static const uint8_t bytes[] = "\x1b[200~hello\x1b[201~"; + boxen_event_t ev; + inject_and_poll(dec, bytes, sizeof(bytes) - 1, BOXEN_OK, &ev); + + assert(ev.type == BOXEN_EV_PASTE); + assert(ev.paste.len == 5); + assert(ev.paste.data != NULL); + assert(memcmp(ev.paste.data, "hello", 5) == 0); + /* No warning on the happy path. */ + assert(g_paste_warn_count == 0); + + free(ev.paste.data); + input_decoder_destroy(dec); +} + +/* 2026-06-29 JES #812 M4: paste with embedded newlines. + * + * The plan calls for CR/LF normalization (terminals typically send CR for + * line breaks inside paste; we normalize to LF so downstream consumers + * see consistent line terminators regardless of how the user's clipboard + * encoded the original text). + * + * Input contains a mix: "a\nb\r\nc\rd" (LF, CRLF, lone CR). + * Expected after normalization: "a\nb\nc\nd" -- every CR / CRLF collapses + * to a single LF; a bare LF passes through unchanged. */ +static void test_bracketed_paste_with_newlines(void) { + paste_log_reset(); + input_decoder_t *dec = input_decoder_create(-1); + assert(dec != NULL); + + static const uint8_t bytes[] = "\x1b[200~a\nb\r\nc\rd\x1b[201~"; + boxen_event_t ev; + inject_and_poll(dec, bytes, sizeof(bytes) - 1, BOXEN_OK, &ev); + + assert(ev.type == BOXEN_EV_PASTE); + assert(ev.paste.data != NULL); + assert(ev.paste.len == 7); + assert(memcmp(ev.paste.data, "a\nb\nc\nd", 7) == 0); + + free(ev.paste.data); + input_decoder_destroy(dec); +} + +/* 2026-06-29 JES #812 M4: paste at the 256 KiB size cap. + * + * Inject a paste whose body is exactly cap+1024 bytes ('x' repeated). The + * decoder must: + * (a) truncate the emitted data to the cap (262144 bytes), + * (b) emit exactly one BOXEN_LOG_W, + * (c) still emit a single well-formed BOXEN_EV_PASTE event when the + * closing \e[201~ arrives, + * (d) resync to GROUND after the close marker (a subsequent printable + * byte produces a normal key event, not garbage). */ +static void test_bracketed_paste_size_cap_truncation(void) { + paste_log_reset(); + input_decoder_t *dec = input_decoder_create(-1); + assert(dec != NULL); + + enum { BODY_LEN = 256 * 1024 + 1024 }; /* cap + 1 KiB */ + enum { EXPECTED_TRUNCATED = 256 * 1024 }; + + /* Heap the body buffer (262K + headers on the stack would push us past + * the default thread stack on some platforms). */ + uint8_t *body = (uint8_t *)malloc(BODY_LEN); + assert(body != NULL); + memset(body, 'x', BODY_LEN); + + /* Open marker + body + close marker, fed in pieces so the decoder + * exercises the multi-inject path that real read(2) bursts will use. */ + static const uint8_t open_marker[] = "\x1b[200~"; + static const uint8_t close_marker[] = "\x1b[201~"; + + /* Open the paste. */ + size_t n = input_decoder_inject_bytes(dec, open_marker, + sizeof(open_marker) - 1); + assert(n == sizeof(open_marker) - 1); + + /* Drain any partial state (open marker alone produces no event). */ + boxen_event_t ev; + int rc = input_decoder_poll(dec, &ev, 0); + assert(rc == BOXEN_ERR_TIMEOUT); + + /* Feed body in chunks small enough to fit in the 4 KiB ring buffer at + * once, draining between each. Real paste flow will be similar: read(2) + * fills the ring, poll() drains. The paste state machine is + * responsible for accumulating bytes into its OWN growing buffer (the + * 4 KiB ring is for inter-poll back-pressure; paste content lives in + * the heap-allocated paste_buf inside the decoder). */ + enum { CHUNK = 1024 }; /* well under INPUT_DECODER_BUFFER_SIZE = 4096 */ + for (size_t off = 0; off < BODY_LEN; off += CHUNK) { + size_t want = (off + CHUNK > BODY_LEN) ? (BODY_LEN - off) : CHUNK; + size_t accepted = input_decoder_inject_bytes(dec, body + off, want); + assert(accepted == want); + rc = input_decoder_poll(dec, &ev, 0); + /* Mid-paste polls return TIMEOUT (no event yet -- still buffering). */ + assert(rc == BOXEN_ERR_TIMEOUT); + } + + /* Close the paste. */ + n = input_decoder_inject_bytes(dec, close_marker, + sizeof(close_marker) - 1); + assert(n == sizeof(close_marker) - 1); + + rc = input_decoder_poll(dec, &ev, 0); + assert(rc == BOXEN_OK); + assert(ev.type == BOXEN_EV_PASTE); + assert(ev.paste.data != NULL); + assert(ev.paste.len == EXPECTED_TRUNCATED); + /* All bytes are 'x'. */ + for (size_t i = 0; i < ev.paste.len; i++) { + assert(ev.paste.data[i] == 'x'); + } + /* Exactly one truncation warning. */ + assert(g_paste_warn_count == 1); + assert(strstr(g_paste_last_log, "paste") != NULL || + strstr(g_paste_last_log, "truncated") != NULL); + + free(ev.paste.data); + free(body); + + /* Resync check: a printable after close-marker must decode as a normal + * key event, proving we returned to GROUND. */ + static const uint8_t one_char[] = "Z"; + inject_and_poll(dec, one_char, sizeof(one_char) - 1, BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_KEY); + assert(ev.key.ch == 'Z'); + + input_decoder_destroy(dec); } -static void test_skip_bracketed_paste_with_newlines(void) { - tr_skip("M4: paste with embedded \\n / \\r\\n -> single PASTE event " - "(plan section 7 M4)"); +/* 2026-06-29 JES #812 M4: ESC bytes inside paste are literal text. + * + * The PASTE_ACTIVE state must NOT re-enter the CSI parser when ESC + * arrives inside the paste body. Only the literal byte sequence + * "\e[201~" terminates the paste; everything else is verbatim content. + * + * Test body: "\e[A\e[B\e]999\\X" between the markers. Without the + * literal-bytes guard, the decoder would parse \e[A as KEY_UP, \e[B as + * KEY_DOWN, etc., losing the paste content entirely. */ +static void test_bracketed_paste_embedded_esc_is_literal(void) { + paste_log_reset(); + input_decoder_t *dec = input_decoder_create(-1); + assert(dec != NULL); + + static const uint8_t bytes[] = + "\x1b[200~""\x1b[A""\x1b[B""\x1b]999\\X""\x1b[201~"; + static const char expected[] = "\x1b[A\x1b[B\x1b]999\\X"; + + boxen_event_t ev; + inject_and_poll(dec, bytes, sizeof(bytes) - 1, BOXEN_OK, &ev); + + assert(ev.type == BOXEN_EV_PASTE); + assert(ev.paste.data != NULL); + assert(ev.paste.len == sizeof(expected) - 1); + assert(memcmp(ev.paste.data, expected, sizeof(expected) - 1) == 0); + + free(ev.paste.data); + input_decoder_destroy(dec); } -static void test_skip_bracketed_paste_size_cap_truncation(void) { - tr_skip("M4: paste > 256 KiB -> truncated + BOXEN_LOG_W " - "(plan section 3.8, R5)"); +/* 2026-06-29 JES #812 M4: empty paste round-trip. + * + * \e[200~\e[201~ with no content should still emit a BOXEN_EV_PASTE + * (len=0). Production REPL handler treats this as a no-op insertion; + * the event still needs to fire so the consumer can clear any + * paste-in-progress UI state. */ +static void test_bracketed_paste_empty(void) { + paste_log_reset(); + input_decoder_t *dec = input_decoder_create(-1); + assert(dec != NULL); + + static const uint8_t bytes[] = "\x1b[200~\x1b[201~"; + boxen_event_t ev; + inject_and_poll(dec, bytes, sizeof(bytes) - 1, BOXEN_OK, &ev); + + assert(ev.type == BOXEN_EV_PASTE); + assert(ev.paste.len == 0); + /* data may be NULL or non-NULL with len==0; either is acceptable per + * the boxen.h contract. Free is a no-op on NULL. */ + free(ev.paste.data); + input_decoder_destroy(dec); } -static void test_skip_bracketed_paste_embedded_esc_is_literal(void) { - tr_skip("M4: ESC sequences inside paste markers -> treated as literal " - "text, not parsed (plan section 7 M4)"); +/* 2026-06-29 JES #812 M4: paste split across multiple inject calls. + * + * Mirrors the M2 partial-sequence tests: split the open marker, body, and + * close marker across multiple inject_bytes / poll calls. The decoder + * state must survive across calls (the PASTE_ACTIVE state and paste_buf + * are decoder fields, not poll-local). */ +static void test_bracketed_paste_split_across_inject(void) { + paste_log_reset(); + input_decoder_t *dec = input_decoder_create(-1); + assert(dec != NULL); + + boxen_event_t ev; + + /* Split 1: half the open marker. */ + static const uint8_t part1[] = "\x1b[20"; + input_decoder_inject_bytes(dec, part1, sizeof(part1) - 1); + int rc = input_decoder_poll(dec, &ev, 0); + assert(rc == BOXEN_ERR_TIMEOUT); + + /* Split 2: rest of open + start of body. */ + static const uint8_t part2[] = "0~hel"; + input_decoder_inject_bytes(dec, part2, sizeof(part2) - 1); + rc = input_decoder_poll(dec, &ev, 0); + assert(rc == BOXEN_ERR_TIMEOUT); + + /* Split 3: rest of body + partial close marker. */ + static const uint8_t part3[] = "lo\x1b[20"; + input_decoder_inject_bytes(dec, part3, sizeof(part3) - 1); + rc = input_decoder_poll(dec, &ev, 0); + assert(rc == BOXEN_ERR_TIMEOUT); + + /* Split 4: finish close marker. */ + static const uint8_t part4[] = "1~"; + input_decoder_inject_bytes(dec, part4, sizeof(part4) - 1); + rc = input_decoder_poll(dec, &ev, 0); + assert(rc == BOXEN_OK); + assert(ev.type == BOXEN_EV_PASTE); + assert(ev.paste.len == 5); + assert(memcmp(ev.paste.data, "hello", 5) == 0); + + free(ev.paste.data); + input_decoder_destroy(dec); } /* ------------------------------------------------------------------------- @@ -2099,11 +2343,13 @@ int main(void) { TR_RUN(test_sgr_mouse_malformed_zero_coord); TR_RUN(test_x10_mouse_malformed_payload_under_min); - /* M4 SKIP -- bracketed paste. */ - TR_RUN(test_skip_bracketed_paste_simple); - TR_RUN(test_skip_bracketed_paste_with_newlines); - TR_RUN(test_skip_bracketed_paste_size_cap_truncation); - TR_RUN(test_skip_bracketed_paste_embedded_esc_is_literal); + /* 2026-06-29 JES #812 M4: bracketed paste + BOXEN_EV_PASTE. */ + TR_RUN(test_bracketed_paste_simple); + TR_RUN(test_bracketed_paste_with_newlines); + TR_RUN(test_bracketed_paste_size_cap_truncation); + TR_RUN(test_bracketed_paste_embedded_esc_is_literal); + TR_RUN(test_bracketed_paste_empty); + TR_RUN(test_bracketed_paste_split_across_inject); /* M5 SKIP -- Kitty keyboard protocol. */ TR_RUN(test_skip_kitty_csi_u_letter); @@ -2116,7 +2362,7 @@ int main(void) { * tally in the test log without polluting the JSON tally itself. * tr_count was updated by TR_RUN as each test ran; g_skip_count tracks * the subset that called tr_skip() instead of asserting behavior. */ - printf("[skip-summary] %d of %d tests are SKIP stubs deferred to M2-M5\n", + printf("[skip-summary] %d of %d tests are SKIP stubs deferred to M5\n", g_skip_count, tr_count); TR_SUMMARY(); From c64a688c4a01958cea6a245979cc1637c59bf546 Mon Sep 17 00:00:00 2001 From: Jake Savin Date: Mon, 29 Jun 2026 21:10:05 -0700 Subject: [PATCH 2/2] fix(boxen): C M4 #812 gate-fix -- paste-append truncate-latch + test cleanup Addresses /gate P2 findings on PR #819. Decoder (input_decoder.c): - paste_buf_append: gate the realloc retry loop behind dec->paste_truncated so a sustained OOM scenario doesn't call malloc on every dropped byte (one realloc attempt per paste -- success or failure latches the result). Cleaner control flow: the early return on paste_truncated covers BOTH the cap path and the prior-OOM path with one check. Tests (boxen_repl_tests.c): - test_paste_oversize_truncates_at_input_cap: drop the redundant `big[2048 - 1] = 'q'` write -- memset already filled every byte. Cosmetic. Verification: input_decoder_tests 127/127, boxen_repl_tests 51/51. --- frontier-cli/boxen/input_decoder.c | 29 ++++++++++++++++++----------- tests/boxen_repl_tests.c | 3 +-- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/frontier-cli/boxen/input_decoder.c b/frontier-cli/boxen/input_decoder.c index 0997f0080..bc74344c6 100644 --- a/frontier-cli/boxen/input_decoder.c +++ b/frontier-cli/boxen/input_decoder.c @@ -872,12 +872,19 @@ static bool decode_sgr_mouse(input_decoder_t *dec, const uint8_t *csi_buf, * mid-flight still emits the partial event so the user sees something * rather than nothing. */ static void paste_buf_append(input_decoder_t *dec, uint8_t b) { + /* 2026-06-29 JES #812 M4 gate-fix: once truncation is latched (cap + * reached OR realloc failed) we are committed to dropping further + * bytes for the rest of this paste -- don't re-enter the realloc + * branch on every subsequent byte (which under sustained OOM would + * call malloc thousands of times for nothing). The early return + * also keeps the BOXEN_LOG_W call sites single-use per paste. */ + if (dec->paste_truncated) { + return; + } if (dec->paste_len >= INPUT_DECODER_PASTE_CAP) { - if (!dec->paste_truncated) { - dec->paste_truncated = true; - BOXEN_LOG_W("bracketed paste exceeds %zu-byte cap; truncated", - (size_t)INPUT_DECODER_PASTE_CAP); - } + dec->paste_truncated = true; + BOXEN_LOG_W("bracketed paste exceeds %zu-byte cap; truncated", + (size_t)INPUT_DECODER_PASTE_CAP); return; } if (dec->paste_len >= dec->paste_cap) { @@ -889,12 +896,12 @@ static void paste_buf_append(input_decoder_t *dec, uint8_t b) { } char *grown = (char *)realloc(dec->paste_buf, new_cap); if (grown == NULL) { - /* OOM: keep what we have, latch truncation, stop appending. */ - if (!dec->paste_truncated) { - dec->paste_truncated = true; - BOXEN_LOG_W("bracketed paste realloc failed at %zu bytes; " - "truncated", dec->paste_len); - } + /* OOM: keep what we have, latch truncation, stop appending. + * realloc preserves dec->paste_buf on failure, so the + * already-buffered bytes remain valid for the eventual emit. */ + dec->paste_truncated = true; + BOXEN_LOG_W("bracketed paste realloc failed at %zu bytes; " + "truncated", dec->paste_len); return; } dec->paste_buf = grown; diff --git a/tests/boxen_repl_tests.c b/tests/boxen_repl_tests.c index e005866a0..639bf416a 100644 --- a/tests/boxen_repl_tests.c +++ b/tests/boxen_repl_tests.c @@ -2218,11 +2218,10 @@ static void test_paste_mid_buffer_inserts_at_cursor(void) { static void test_paste_oversize_truncates_at_input_cap(void) { setup(); - /* Build a 2048-byte paste body. */ + /* Build a 2048-byte paste body (all 'q'). */ char *big = (char *)malloc(2048); assert(big != NULL); memset(big, 'q', 2048); - big[2048 - 1] = 'q'; boxen_event_t ev; memset(&ev, 0, sizeof(ev));