buzz-acp can merge NDJSON frames when a prompt write is cancelled
- Dominant language
- Rust
- Stars
- 32.7k
- Forks
- 4.3k
- Avg merge
- 1d 13h
- Merged PRs (30d)
- 253
Description
## Summary
`buzz-acp` can leave a partial `session/prompt` frame in an ACP agent's stdin when the prompt future is cancelled. The cleanup path then writes `session/cancel` immediately after those bytes, producing one malformed NDJSON line instead of two frames.
## Production evidence
Observed with `buzz-acp` from `desktop-v0.5.4` (`651f637`) while consuming a burst with `--subscribe all`:
- captured malformed frame: **65,773 bytes** (65,774-byte artifact including its trailing newline)
- parser failure: `Expected ',' or '}' after property value in JSON at position 65236`
- artifact SHA-256: `3d4fa5287a3fe7e556d5e0e06b634d4c1e83dc414393fc1113399fc5be3dc390`
- the boundary in the captured line is a truncated prompt payload followed directly by:
```text
...{"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":"..."}}
```
The raw capture contains channel event payloads, so I have not uploaded it publicly. The regression test in the proposed fix recreates the same write/cancel race without production data.
## Root cause
The prompt turn is cancellable through `tokio::select!`. `write_ndjson` currently performs `write_all(line)`, `write_all("\\n")`, and `flush()` inside that cancellable future. If the future is dropped while `write_all` is pending, bytes already written remain in the pipe. `cancel_with_cleanup` subsequently writes another JSON-RPC frame to the same stdin, fusing the two.
## Expected behavior
Every write to agent stdin is a complete NDJSON frame. Cancelling the caller may enqueue the whole frame or enqueue nothing, but must never leave a partial frame for the next write to append to.
## Proposed fix
Give stdin to one writer task. Callers serialize a complete frame and submit it through a bounded channel; the actor alone performs `write_all` + newline + `flush`. A regression test uses `tokio::io::duplex` and the production writer/`LinesCodec`, cancels a >256 KiB prompt write, sends `session/cancel`, and requires two independently valid JSON lines.
Contributor guide
Research direction
Start with write_ndjson and cancel_with_cleanup, then trace the cancellable tokio::select! path for the prompt turn. Read the production writer and LinesCodec usage, and run the regression test with tokio::io::duplex; done means a cancelled large prompt write followed by session/cancel produces two independently valid JSON lines.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- rust
- Domain
- api, backend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 64/100