Skip to content

[DV-12] [P2] Frame hot-reload stdout by LF-delimited bytes before applying limits and decoding #572

Closed

Description

@wsdt

Priority: P2
Area: Editor hot reload
Repository / target branch: wavect/semaprax / wavect/v090
Reviewed commit: 5428b0805fa3cf35859c843a53e30c3252ef378c
Review date: 6 October 2026

Problem and observed impact

The dedicated VS Code hot-reload controller applies its 8 KiB response limit to an incoming stdout chunk before splitting LF-delimited responses. A legal collection of small responses can therefore terminate the session solely because the operating system delivered them together. The same handler decodes individual chunks as UTF-8, corrupting valid multibyte characters split across reads.

The production HotReload class was executed with deterministic child-stream fixtures on Node 24.19.0. All responses used the current CLI status shape, including terminal_uncertainty and watch_state.

Identical response bytes Observed controller behavior
48 frames delivered in one chunk: 11,416 bytes total, largest individual frame 238 bytes unknown, response exceeds its bound, child killed, zero frames accepted
Those 48 frames delivered separately All accepted, no outstanding requests, child remains running

The 48 requests are below both the CLI's 64-request lifetime bound and the controller's 64-pending-request bound. No individual response approaches 8 KiB. The standalone control contract defines a per-response limit and explicitly uses LF framing; stdout read boundaries are not protocol boundaries.

This was a direct execution of the production class with controlled stream events, not a real compiler/Extension Host run. The fragmentation comparison isolates the bug without depending on nondeterministic OS batching.

Minimal reproduction

Run this from a checkout or set SEMAPRAX_REPO to its absolute path:

const path = require('node:path');
const { EventEmitter } = require('node:events');
const { HotReload } = require(path.join(
  process.env.SEMAPRAX_REPO || process.cwd(),
  'editors/vscode/hot-reload.js'));
const child = new EventEmitter();
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.stdin = { write: () => true };
child.kill = () => {};
const reload = new HotReload(() => child,
  process.execPath, path.resolve('semaprax.toml'));
reload.start();
for (let id = 2; id <= 48; id++) reload.request('status');
const frames = Array.from({ length: 48 }, (_, i) => Buffer.from(
  JSON.stringify({ schema: 'semaprax.hot-reload-control.v1',
    id: i + 1, event: i ? 'status' : 'started', generation: 0,
    active_project_revision: 'sha256:' + 'a'.repeat(64),
    terminal_uncertainty: false, watch_state: 'watching' }) + '\n'));
child.stdout.emit('data', Buffer.concat(frames));
console.log(reload.detail());

Repeat with a fresh controller, emitting each frames entry separately. It succeeds. The accompanying hot-reload-framing.cjs performs both variants and records the counts.

For the decoding case, send a valid rejected response with message Straße 🧪 to another fresh controller. Split its encoded Buffer immediately after the first UTF-8 byte of the emoji. The reviewed handler reports Straße ����; an unsplit response preserves the original text. This is valid UTF-8 divided at a legal transport boundary, not malformed compiler output.

Root cause and focused fix

#data first executes this.buffer += chunk.toString('utf8'), then checks the size of the complete string against MAX_FRAME, and only afterward searches for LF. Consequently, multiple valid frames share one temporary capacity check, while partial code points are irreversibly replaced before the next chunk arrives.

Maintain a bounded byte accumulator for the current incomplete frame. Scan each incoming chunk for LF, enforce the limit independently for every completed/incomplete frame, and decode a complete frame strictly. A correctly finalized streaming decoder is also acceptable, provided its accounting remains based on wire bytes. Process complete frames before retaining the next frame's tail; do not increase the protocol limit to hide the bug.

Preserve request IDs, epoch binding, closed-state behavior, pending limits, and terminal uncertainty. A framing failure after a dispatched activation must remain non-replayable. Reuse a small helper if useful, while retaining the hot-reload protocol's separation from MCP. Handle a trailing partial frame or incomplete UTF-8 sequence on termination according to the existing fail-closed policy.

Acceptance criteria

  • The same legal wire bytes yield the same state under one chunk, per-frame chunks, mixed chunks, and single-byte fragmentation.
  • Multiple valid frames totaling more than 8 KiB are accepted when each frame and the request count remain within their bounds.
  • One oversized complete or incomplete frame fails with bounded memory; raising the cap is not the fix.
  • Valid UTF-8 survives every code-point split, including supplementary characters and literal U+FFFD.
  • Malformed UTF-8 and truncated final sequences produce explicit transport failure rather than successful replacement-decoded content.
  • Stop, stale epochs, request matching, and activation uncertainty retain their existing behavior.

Existing issue boundary and implementation handoff

#69 repaired chunk accumulation in the separate MCP client. #535/REF-16 repaired final UTF-8 admission in runCheck and runCommand. This dedicated hot-reload handler remains defective; its primary new failure is the per-chunk capacity check.

Read AGENTS.md, the hot-reload transport specification, and applicable quality gates. Extend the existing controller tests; its nine tests passed during review. Add the corresponding real-host transport fixture where feasible and report actual executed counts, failures, skips, and platform coverage. No protocol version change is needed to honor the existing framing contract.

Activity

  1. wsdt commented on Oct 6, 2026

    @wsdt
    MemberAuthor

    Fixed in 0c6ae4a (pushed to main). Hot-reload stdout is now a bounded byte accumulator split on LF; each frame (LF included) is capped at 8192 bytes regardless of read boundaries, decoded as strict UTF-8; oversized, malformed, truncated-tail and exit-mid-frame are terminal; CRLF accepted. docs/HOT-RELOAD-WATCHER-V1.md updated. Evidence: 205/205 Node tests incl. 48 frames batched in one read, single-byte and mixed chunking, every UTF-8 split point of a multi-byte string.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions