Long sessions become permanently unopenable in the desktop: `session/load` replay outruns the ACP transport and the connection is dropped
- Langage dominant
- Rust
- Étoiles
- 54.2k
- Forks
- 6.2k
- Merge moyen
- 3 j 2 h
- PR mergées (30 j)
- 262
Description
**Symptom:** opening one particular chat shows *"Failed to Load Session — ACP connection closed"*.
It reproduces every time for that chat, while every other chat in the same store opens normally.
The same session loads fine from the CLI (`goose session --resume --session-id … --fork`).
**Versions:** goose 1.41.x desktop, `agent-client-protocol-http` 1.0.1. The transport behaviour
below is unchanged in that crate's current 2.0.0.
## What happens
`session/load` replays the stored conversation to the client as one notification per content
block, in a tight loop over messages read from SQLite
(`crates/goose/src/acp/server/load_session.rs`, `replay_conversation_to_client`). There is no
flow control: the producer pushes as fast as it can deserialise rows.
`agent-client-protocol-http` gives each subscriber a bounded queue
(`OUTBOUND_STREAM_CAPACITY = 1024`) and, on a full `try_send`, **removes the subscriber**
(`connection.rs:61-70`). The WebSocket writer then sees `recv() == None`, breaks, and drops the
socket with no close frame. The client's ACP SDK rejects the in-flight `session/load` with
`ACP connection closed`.
So any session long enough for the replay to get 1024 notifications ahead of the writer is
**permanently unopenable in the desktop**, while remaining perfectly loadable everywhere else.
The transport half is filed separately as
[agentclientprotocol/rust-sdk#337](https://github.com/agentclientprotocol/rust-sdk/issues/337),
since the drop-on-full policy is theirs to decide. This issue is the goose half: the replay is
what generates the burst, and goose is where the user-visible damage lands.
## Why it is hard to diagnose from the goose side
Everything a user or maintainer can normally look at says the backend is healthy, because it is:
- the `goose serve` process **does not exit** — only the subscriber is dropped;
- nothing is logged. The transport's explanation is a `debug!`, and goose's default filter is
`goose=info` + WARN, so it is discarded;
- the backend's own tracing log simply **stops** mid-load, because the `session/load` future is
cancelled along with the connection — which reads like a hang or a crash;
- `goose serve`'s stderr is not a fallback either: a panic there would print to stderr and
nowhere else, so "no panic in the log file" does not rule a panic out.
It took several hours and five rounds of log collection with an affected user to get to the one
`debug!` line. Anything that makes this class self-diagnosing would be worth a lot.
## Reproducer
`goose_repro.py`, attached. Self-contained: it synthesises a session of a given shape in a
scratch `GOOSE_PATH_ROOT`, starts `goose serve`, and drives `initialize` + `session/load` over
the ACP WebSocket exactly as `ui/desktop/src/acp/sessions.ts::loadAcpSession` does. It cleans up
after itself.
```sh
python3 goose_repro.py --goose /path/to/goose --tls # reproduces, exit 1
python3 goose_repro.py --goose /path/to/goose # control, exit 0
```
**TLS is required to reproduce**, and that detail matters: it slows the writer just enough for
the producer to win the race. The desktop always serves ACP over `wss://` with a generated,
pinned self-signed certificate (`gooseServe.ts` passes `--tls`), so production is always on the
failing side. Plain `ws://` completes cleanly with the identical store — which is why this does
not show up in any test that skips the real transport.
Measured on one machine:
| messages | payload | `ws://` | `wss://` |
|---|---|---|---|
| 3000 | 0.7 MiB | clean | clean |
| 900 | 92 MiB | clean | clean |
| 1674 | 16 MiB | clean | clean |
| 1674 | 28 MiB | clean | **fails** |
| 1674 | 171 MiB | clean | **fails** |
Both conditions are needed: more than ~1024 replayed blocks, and enough bytes for the writer to
be the bottleneck. The real session that prompted this was 1674 messages and roughly 40 MB,
mostly base64 PNGs from tool results that render images.
## Why goose cannot simply fix this itself
`cx.send_notification` is synchronous — it returns as soon as the message is queued — so
`replay_conversation_to_client` has no drain signal to await and no way to apply backpressure
from where the burst is generated. Pacing with sleeps or `yield_now` does not help: neither waits
for the writer to actually drain, and the producer is orders of magnitude faster.
That is why the primary fix has to be in the transport, and why it is filed there
([rust-sdk#337](https://github.com/agentclientprotocol/rust-sdk/issues/337)). Two things goose
could still do, independently of how that lands:
1. **Reduce replay volume.** One notification per content block is a lot of small messages, and
replaying full base64 image payloads on every session load is expensive well beyond this bug —
a session that renders images re-ships every one of them on each reopen. Batching blocks, or
replaying image content by reference rather than by value, would cut both the message count
and the byte volume that feed the race.
2. **Surface the disconnect.** Right now the UI can only say "ACP connection closed". If the
transport gains a close reason, plumbing it into `sessionLoadError`
(`ui/desktop/src/acp/chatSessionController.ts:161`) would turn a mystery into a message.
Guide de contribution
Ouvrir le guide de contribution
Évaluation
Cette issue n'a pas encore été évaluée.