Repository navigation
Description
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
VS Code: compiler selection/repair flow and hot-reload stdin/framing …
wsdt commentedon Oct 6, 2026
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.