block / block/buzz

buzz-acp can merge NDJSON frames when a prompt write is cancelled

Open
#6,671 0 comments 0 reactions 0 assignees View on GitHub
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.