Skip to content

Commit 36ef9a8

Browse files
committed
Preserve computed metrics when the JSON-RPC message backlog overflows
2 parents a4fce79 + f4b5cf5 commit 36ef9a8

4 files changed

Lines changed: 25 additions & 17 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
109109
- `FirstLineAsync` now preserves cancellation that arrives after a matching line, including while the child is being reaped, and no longer masks a `FinishAsync` error.
110110
- Linux cgroup legacy hard kills now verify that the reusable cgroup thawed and surface a typed `ProcessError.Io` through `ProcessGroup.KillAll()` and `Signal.Kill` instead of reporting success for a group still frozen; final disposal remains best-effort.
111111
- `JsonRpcSession` now gives an already-claimed response priority over a concurrent timeout or cancellation, so a successful answer cannot be replaced by a typed deadline error during pending-request completion.
112+
- `JsonRpcSession` no longer fabricates total message or byte counts when a peer request is evicted from its decoded-message backlog: the terminal `ProcessError.OutputTooLarge` reports zero for both uncounted totals, while notification drops remain counted by `DroppedMessages` and session termination is unchanged.
112113
- POSIX `LaunchDetached` now transfers each direct child to a private background reaper, preventing zombies in long-lived parents while preserving the pid-and-start-time-only detached API.
113114
- Ending a POSIX `Pty` run's stdin now actually reaches the child: a drained `Command.Stdin` source, `ProcessStdin.FinishAsync`, and `PtySession.CloseStdinAsync` deliver the terminal's configured end-of-input character (`termios.c_cc[VEOF]`, read from the pty rather than assumed to be Ctrl-D) twice — terminating an unterminated line and then ending input — so a child reading to EOF such as `cat` or a shell `read` loop finishes instead of hanging; the shared pty master stays open and owned by the merged output stream, writes through a finished stdin handle are refused, and a delivery that genuinely fails is now reported (`IOException`, or a typed `ProcessError.Io`/`ProcessError.Stdin`) instead of silently dropped.
114115
- `WaitForPortAsync`, `WaitForSocketAsync`, `WaitForHttpAsync`, and `WaitForAsync` now check their condition exactly one more time — bounded by the remaining timeout, a brief grace, and the caller's token — after observing the child's exit, so readiness published immediately before that exit reports `Ok` instead of being lost as `NotReady`; a `WaitForAsync` predicate is therefore invoked once more after the child exits, unless the token is already cancelled or the deadline already spent.

‎README.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -870,7 +870,10 @@ waits, and disposal release the parked writer before waiting for the process out
870870
For conversational language-server, build-server, or MCP-style children, use the
871871
[JSON-RPC 2.0 session layer](docs/streaming.md#json-rpc-sessions-lsp--bsp--mcp). It rejects every
872872
incoming frame whose `jsonrpc` member is missing, non-string, or not exactly `"2.0"` with a typed
873-
`ProcessError.Parse` before request, notification, or response routing.
873+
`ProcessError.Parse` before request, notification, or response routing. Its decoded-message backlog
874+
may drop old notifications, counted by `DroppedMessages`; evicting a peer request ends the session
875+
with `ProcessError.OutputTooLarge`, whose totals are zero because this backlog does not count total
876+
messages or bytes.
874877

875878
### Interactive stdin — write requests, read responses
876879

‎src/ProcessKit/JsonRpcSession.fs‎

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,9 @@ type private DropCounter() =
198198
/// **Two backlogs, two knobs.** `messageBacklog` (the third constructor argument, 1024 by default)
199199
/// bounds the DECODED incoming messages waiting for `MessagesAsync`. Old notifications may be dropped
200200
/// and are counted in `DroppedMessages`; a peer request is never silently dropped — if it would be
201-
/// evicted, the conversation ends with `ProcessError.OutputTooLarge`. `Command.StreamBuffer` bounds the
201+
/// evicted, the conversation ends with `ProcessError.OutputTooLarge`. The decoded-message backlog does
202+
/// not count a total message or byte volume, so that error carries zero totals (the `ProcessError`
203+
/// convention for an unreported metric). `Command.StreamBuffer` bounds the
202204
/// raw frame backlog underneath it, through the `ContentLengthSession` this session owns, and only its
203205
/// two LOSSLESS full modes apply there: `Backpressure` paces the peer against the router, and `Error`
204206
/// ends the conversation with `ProcessError.OutputTooLarge` at the cap. The two DROP modes are refused
@@ -271,13 +273,9 @@ type JsonRpcSession
271273
)
272274
273275
let messageBacklogOverflow () =
274-
let totalMessages =
275-
if messageBacklog = Int32.MaxValue then
276-
Int32.MaxValue
277-
else
278-
messageBacklog + 1
279-
280-
ProcessError.OutputTooLarge(program, None, None, totalMessages, 0)
276+
// This backlog counts dropped notifications, but not total messages or bytes. Zero is the
277+
// ProcessError convention for a metric this producer did not measure.
278+
ProcessError.OutputTooLarge(program, None, None, 0, 0)
281279
282280
let parseFailure (detail: string) = ProcessError.Parse(program, detail)
283281

‎tests/ProcessKit.Tests/JsonRpcSessionTests.fs‎

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1705,7 +1705,7 @@ type JsonRpcSessionTests() =
17051705
:> Task
17061706

17071707
[<Test>]
1708-
member _.``evicting a peer request faults a mixed inbound backlog instead of losing it silently``() : Task =
1708+
member _.``evicting a peer request after notification drops reports no fabricated totals``() : Task =
17091709
task {
17101710
let peer = peerHandle ()
17111711
use running = peer.Running
@@ -1716,30 +1716,36 @@ type JsonRpcSessionTests() =
17161716

17171717
peer.Stdout.Emit(framed "{\"jsonrpc\":\"2.0\",\"method\":\"note/1\"}")
17181718

1719-
peer.Stdout.Emit(framed "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"workspace/configuration\"}")
1720-
17211719
peer.Stdout.Emit(framed "{\"jsonrpc\":\"2.0\",\"method\":\"note/2\"}")
17221720
peer.Stdout.Emit(framed "{\"jsonrpc\":\"2.0\",\"method\":\"note/3\"}")
1721+
peer.Stdout.Emit(framed "{\"jsonrpc\":\"2.0\",\"method\":\"note/4\"}")
1722+
1723+
peer.Stdout.Emit(framed "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"workspace/configuration\"}")
1724+
1725+
peer.Stdout.Emit(framed "{\"jsonrpc\":\"2.0\",\"method\":\"note/5\"}")
1726+
peer.Stdout.Emit(framed "{\"jsonrpc\":\"2.0\",\"method\":\"note/6\"}")
17231727

17241728
// The pending local request is completed by endSession, so this is a deterministic fence
17251729
// for the overflow without a delay or a response frame the faulted router could not read.
17261730
match! call with
17271731
| Error(ProcessError.OutputTooLarge(program, None, None, totalMessages, totalBytes)) ->
17281732
Assert.That(program, Is.EqualTo "language-server")
1729-
Assert.That(totalMessages, Is.EqualTo 3)
1733+
Assert.That(totalMessages, Is.EqualTo 0)
17301734
Assert.That(totalBytes, Is.EqualTo 0)
17311735
| other -> Assert.Fail $"expected a typed decoded-backlog overflow, got {other}"
17321736

1733-
let droppedMessage = "only the admissibly lossy notification is counted as dropped"
1734-
Assert.That(session.DroppedMessages, Is.EqualTo 1L, droppedMessage)
1737+
let droppedMessage =
1738+
"only notifications are counted as dropped; the evicted request is not"
1739+
1740+
Assert.That(session.DroppedMessages, Is.EqualTo 4L, droppedMessage)
17351741

17361742
match! session.RequestRawAsync("shutdown", null) with
17371743
| Error(ProcessError.OutputTooLarge _) -> ()
17381744
| other -> Assert.Fail $"expected the overflow to remain the session's terminal error, got {other}"
17391745

17401746
let messages = session.MessagesAsync().GetAsyncEnumerator()
17411747

1742-
for expected in [ "note/2"; "note/3" ] do
1748+
for expected in [ "note/5"; "note/6" ] do
17431749
let! received = messages.MoveNextAsync()
17441750
Assert.That(received, Is.True)
17451751
Assert.That(messages.Current.Method, Is.EqualTo expected)
@@ -1751,7 +1757,7 @@ type JsonRpcSessionTests() =
17511757
| null -> Assert.Fail "expected the decoded-message stream to fault after draining retained notifications"
17521758
| error ->
17531759
match error.Error with
1754-
| ProcessError.OutputTooLarge(_, None, None, 3, 0) -> ()
1760+
| ProcessError.OutputTooLarge(_, None, None, 0, 0) -> ()
17551761
| other -> Assert.Fail $"expected the same OutputTooLarge terminal error, got {other}"
17561762

17571763
do! messages.DisposeAsync()

0 commit comments

Comments
 (0)