Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,19 @@ policy in [docs/versioning.md](docs/versioning.md).

### Added

- **`SessionRegistry::Kick(id, why, grace)`: the kick that does not wait
for the peer** (ADR-0022 amendment). `Close(id)` runs the closing
handshake, which against a peer that has gone silent waits out
`request_timeout_seconds` (30s by default) while the id stays taken.
`Kick` puts a close code and reason on the frame and tears the
connection down after at most `grace`. The same call is
`EventStreamHandle::Kick` and `WebSocket::Kick`;
`WebSocket::PeerCloseReason()` reads the close the peer ended a session
with, so a client told 4001 "superseded" can stop redialing. The base
`WebSocket` defaults keep other implementations compiling: `Kick` is
`Close()`, `PeerCloseReason()` reads nothing. A code other than 1000 or
3000-4999, or a reason over 123 bytes, fails fast.

- **`Observe` labels requests from their headers** (issue #235). An optional
fifth argument, `RequestLabeler`, maps a request's headers to
`RequestLabels` once, before dispatch; the result rides on both
Expand Down Expand Up @@ -262,6 +275,23 @@ policy in [docs/versioning.md](docs/versioning.md).

### Fixed

- **Move-assigning over a stream that had shared its session no longer
closes a released socket.** The defaulted move-assignment of
`EventStream` and `AsyncEventStream` assigned the socket before the
shared view, so ending the old session (the view's assignment) called
`Close()` on a socket the stream might already have freed. Both now end
the old session first.

- **A handle call no longer deadlocks when the transport ends the loop
inline.** The in-memory pair completes parked operations on the calling
thread, so a hub calling `registry.Close(id)` on a session parked in
`co_await Receive()` — or delivering to a peer that answered by closing
— destroyed the stream inside that call, and the stream's teardown
waited forever for the pin the call held. A handle on a stream that owns
its socket (`AsyncEventStream`, or an `EventStream` built from a
`shared_ptr`) now keeps the socket alive by ownership rather than by a
pin, for every operation.

- **A dialed WebSocket released on its own io thread no longer terminates
the process.** When the last handle to a `BeastWebSocketClient::Dial`
socket dropped inside one of its own completions (typically a `Detached`
Expand Down
14 changes: 8 additions & 6 deletions MODULE.bazel.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

60 changes: 58 additions & 2 deletions docs/adr/0022-registry-admission-primitives.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
and ADR-0021 review panels twice deferred. Implemented:
`SessionRegistry::{Admission, ResumeOrAdd, Close}`, the three example
admission loops deleted, the production guide's canonical block replaced
by the one call.
by the one call. Amended 2026-10-13: `Close` does not bound a silent
peer, so the kick is `Kick` (below).

## Context

Expand Down Expand Up @@ -53,6 +54,61 @@ convergent path instead of waiting out TCP. On a detached entry it
closes an already-closed handle: a harmless no-op — detached entries
are `Resume`'s and `Remove`'s business.

## Amendment (2026-10-13): Kick

`Close` was meant to be the kick for "a silent partition … instead of
waiting out TCP", and on Beast it is not one. Its close is the graceful
handshake: send the close frame, then read until the peer's close
arrives, bounded only by Beast's handshake timeout — the transport's
`request_timeout_seconds`, 30s by default. A peer that is gone never
answers, so the old handler holds the id for that long, and a
`ResumeOrAdd` behind it is refused the whole time. games_hub found it:
a phone that dropped off the network kept its player locked out of a
chess game for half a minute after it came back.

**`Kick(id, why, grace) -> bool`** is the kick. It ends the session like
`Close`, with two differences, both carried by new `WebSocket` members
(the runtime's transports — Beast, the in-memory pair, the dialed
client, the JSON-RPC wrapper — override them; the base defaults to
`Close()` and reads nothing, which is what test fakes and the consumer
example's socket get):

- **`WebSocket::Kick(CloseReason why, grace)`** puts `why` — a close code
and reason — on the close frame and waits at most `grace` for the
peer's answer before tearing the connection down. On Beast the kick
arms its own deadline that closes the socket `grace` later, whatever
the close is waiting on — the peer's answer, a keep-alive frame
holding Beast's write lock, a Close's handshake already running, or a
close deferred behind a wedged write. Only the first kick arms it, so a
second neither re-arms it nor cuts the first one's frame short. A peer
that is still there reads the code; one that is gone costs `grace`.
- **`WebSocket::PeerCloseReason()`** is the close frame the peer ended the
session with. With an application code (4000-4999) the server can say
*why* it dropped a client — "superseded by a newer connection" — and a
client can stop redialing instead of taking its seat back, which is what
keeps two connections under one identity from trading it forever.

`EventStreamHandle::Kick` is the handle's form. Codes outside 1000 and
3000-4999, or a reason over 123 bytes, fail fast (ADR-0009): the
protocol's own codes are the transport's to send.

`Close` keeps its meaning — the polite end of a session whose peer is
expected to answer — and its doc no longer claims the silent-partition
case.

A handle's operations also stopped pinning a socket the stream owns. A
transport may complete operations inline on the calling thread — the
in-memory pair does, as its contract allows — and the loop then ends and
destroys its stream inside the handle's call, where the stream's teardown
waited for the pin that call held. Any hub calling `Close(id)` on a
pair-backed session parked in `Receive` deadlocked, and so did a delivery
the peer answered by closing. When the stream owns its socket (the
coroutine adapter always does; a blocking `EventStream` built from a
`shared_ptr` does too), a handle now holds a strong reference across each
call (to completion, for `SendAsync`) instead; a borrowed socket is still
pinned, and a borrowing stream is never torn down by its own socket's
completions.

## Non-goals

An async admission shape (`co_await ResumeOrAdd`) — admission runs
Expand All @@ -67,6 +123,6 @@ Changing `Resume`/`Add` themselves: the primitives compose unchanged.
Consumers write one call and branch on the enum; the retry cadence and
ordering subtleties live (and are tested) in exactly one place. The
guide teaches the call instead of the loop. The silent-partition story
gains its missing move. The registry's public surface grows two
gains its missing move: `Kick`, since the amendment above. The registry's public surface grows two
methods and one enum, all shallow compositions of existing primitives —
no new locking, no new threads, no new states.
24 changes: 20 additions & 4 deletions docs/production-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -1070,13 +1070,29 @@ deadline (the old hand-rolled recipe's 20 × 50ms) comfortably covers the
failure-notice race. The call blocks up to the deadline — legal because
admission runs before the handler's first suspension, on the launching
thread. A `kRefused` you
*know* is wrong — a half-dead session whose wire never sent a FIN — has a
convergent answer now: `registry.Close(id)` kicks the old session — its
*know* is wrong — a half-dead session whose wire never sent a FIN, or an
older connection the same identity has replaced — has a convergent
answer: `registry.Kick(id, why, grace)` kicks the old session — its
handler observes the close and runs the normal exit path, so the id is
admittable on the next dial (freed outright after a Remove exit;
parked-resumable after a Detach exit, where the redial resumes with the
old identity and gets the snapshot). Kicking stays the application's call;
`ResumeOrAdd` never does it on its own.
old identity and gets the snapshot). `grace` bounds the wait for a peer
that never answers; `Close(id)` would wait out the whole closing
handshake (`request_timeout_seconds`). Put an application close code in
`why` (4000-4999) so a kicked client that is still there can tell it was
superseded and stop redialing — `PeerCloseReason()` on a native client,
`CloseEvent.code` in a browser:

```cpp
auto admission = registry.ResumeOrAdd(id, mint, std::chrono::milliseconds(0));
if (admission == Registry::Admission::kRefused) {
registry.Kick(id, {.code = 4001, .reason = "superseded"}, std::chrono::milliseconds(250));
admission = registry.ResumeOrAdd(id, mint, std::chrono::seconds(1));
}
```

Kicking stays the application's call; `ResumeOrAdd` never does it on its
own.

Say the posture out loud in your protocol docs, because it shapes client
code: **recovery is snapshot replay, not message replay.** ADR-0016's
Expand Down
2 changes: 1 addition & 1 deletion docs/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ crates (PLAN §3.2a).
| `//runtime:http_beast` | `opal::http` | `BeastServerTransport` (ADR-0006): the production server transport on BCR modular Boost.Beast/asio — concurrent connections on a thread pool, keep-alive, per-connection timeouts, body- and header-size limits, graceful drain on Stop. WebSocket upgrades ride the same transport (ADR-0015): `Options::on_websocket`/`websocket_gate` serve blocking full-duplex `opal::http::WebSocket` sessions carrying event-stream messages, and `BeastWebSocketClient::Dial` is the client end. `Options::websocket_accept_json_frames` negotiates the ADR-0018 JSON-text wire for clients offering the subprotocol (browsers), transparently to everything above the session; `Options::websocket_raw_text_frames` flips the whole listener to the unnegotiated raw-text wire (ADR-0023: headerless messages as verbatim text frames, one JSON-RPC envelope each — what a generated jsonRpc2 streaming server mounts), with `WebSocketDialRequest::raw_text_frames` as the client end. Separate target so Boost stays out of dep-light builds |
| `//runtime:client` | `opal` | `ClientConfig` (endpoint, timeout, user-agent, transport injection, `RetryPolicy`, request-compression threshold, `Interceptor` hooks around every attempt, `bearer_token`/`api_key` credential providers), `SendWithRetries` (full-jitter exponential backoff over transport errors and 429/5xx), `ObserveAttempts` + `PropagateTraceContext` interceptors — see docs/production-guide.md |
| `//runtime:compression` | `opal` | `GzipCompress`/`GzipDecompress` (zlib; decompression-bomb guard, trailing-garbage rejection) backing `@requestCompression` |
| `//runtime:server` | `opal::server` | `Router` (literal > label > greedy precedence, 404/405/400), `RequestContext`, `MakeErrorResponse`, `ValidationFailure`, user-supplied `Middleware` + `Chain`, the `Observe` logging/metrics hook, `RequireBearerAuth`/`RequireApiKeyHeader` guards, `WebSocketRouter` — the streaming routes' method + URI-pattern dispatch behind generated `StreamRouter()`, sharing `Router`'s matching by construction (ADR-0016), plus the application-facing shared-seam mount `AddSession`/`ServeSession()` for `on_websocket_session` (ADR-0019, issue #118; one router serves one seam) — `RequireOrigin`, the browser-facing Origin-allowlist `websocket_gate` (ADR-0018) — and `SessionRegistry` — the multi-client fan-out helper over owning `EventStream::Share()` handles: bounded per-session outbound queues, non-blocking `SendTo`/`Broadcast` with per-recipient construction, a slow-consumer policy, and `Drain` for graceful shutdown (ADR-0017); `Options::async_delivery` swaps writer threads for `SendAsync` completion chains (ADR-0019); `Detach`/`Resume` + `Options::grace_period` give abrupt disconnects a reconnect window with exactly-once expiry cleanup (ADR-0020); `ResumeOrAdd` is the blessed admission call and `Close(id)` the kick primitive (ADR-0022) |
| `//runtime:server` | `opal::server` | `Router` (literal > label > greedy precedence, 404/405/400), `RequestContext`, `MakeErrorResponse`, `ValidationFailure`, user-supplied `Middleware` + `Chain`, the `Observe` logging/metrics hook, `RequireBearerAuth`/`RequireApiKeyHeader` guards, `WebSocketRouter` — the streaming routes' method + URI-pattern dispatch behind generated `StreamRouter()`, sharing `Router`'s matching by construction (ADR-0016), plus the application-facing shared-seam mount `AddSession`/`ServeSession()` for `on_websocket_session` (ADR-0019, issue #118; one router serves one seam) — `RequireOrigin`, the browser-facing Origin-allowlist `websocket_gate` (ADR-0018) — and `SessionRegistry` — the multi-client fan-out helper over owning `EventStream::Share()` handles: bounded per-session outbound queues, non-blocking `SendTo`/`Broadcast` with per-recipient construction, a slow-consumer policy, and `Drain` for graceful shutdown (ADR-0017); `Options::async_delivery` swaps writer threads for `SendAsync` completion chains (ADR-0019); `Detach`/`Resume` + `Options::grace_period` give abrupt disconnects a reconnect window with exactly-once expiry cleanup (ADR-0020); `ResumeOrAdd` is the blessed admission call and `Kick(id, why, grace)` the kick primitive (ADR-0022) |

## Design rules

Expand Down
Loading
Loading