fix: keep the client subscribed when the initial plugin diff fails - #142
Merged
Conversation
send_event_all reached no clients because the subscription was already gone by the time it ran -- not because the broadcast itself was broken. ## Root cause `sparus()`'s init task pushed `Err(Status)` into the stream on three paths (DB error from get_plugin_version, and two semver parse failures). In tonic, an `Err` item in a *server-streaming* body is not an in-band error the client can skip: `EncodeBody::poll_frame` converts it into the HTTP/2 TRAILERS frame and sets `is_end_stream = true`. The response is over. `rx` is then dropped, `tx.closed()` resolves, and the cleanup task removes the client from the registry -- a few milliseconds after it connected. Every later `send_event_all` iterates an empty map and returns `Ok(Empty)`, so the web UI reports success for a broadcast that reached nobody. The realistic trigger is `get_plugin_version`, which was called unconditionally even when the intersection was empty, so it hit the database on *every* connection. It fails whenever there's no pool configured, or the `plugins` table doesn't exist (migrations only run inside create_database, never at startup), or a stored version isn't valid semver. ## Fix - Never send `Err(Status)` on the stream channel; log and carry on. A failure to compute the *initial* diff is not a reason to drop the subscription. A bad version string now skips that one plugin instead of tearing down the whole stream. - Skip `get_plugin_version` entirely when there's nothing in common, instead of querying the DB just to get an empty map back. - Use the `EventType` enum instead of bare 1 / 2 literals. ## Also fixed, same symptom - `send_event_all` cloned nothing and awaited `send()` while holding the registry lock. One slow client (Sparus stops polling the stream while it downloads a plugin, and the channel only holds 32) blocked delivery to every other client *and* blocked `sparus()` from registering new ones. Now: clone the senders, drop the lock, `try_send`. - `send_event_all` had no logging at all, which is why this was hard to diagnose -- it returns Empty whether it reached 12 clients or 0. It now logs the delivered count, and warns when that count is zero. - The web UI offered a fourth event type, `3: "Update Frontend"`, which doesn't exist in sparus.proto (INSTALL/UPDATE/DELETE only). proto3 open enums let it through the wire, and then the launcher's `EventType::try_from(3)` fails, which ends its stream loop and unsubscribes it permanently. Clicking that button once would stop a launcher receiving anything ever again. Removed it -- if you want a frontend-update event, it needs a value in the proto enum plus handling in Sparus first. - The Broadcast button dropped the promise, so a failed RPC looked exactly like a successful one. It now surfaces the error. - Fixed two pre-existing `tsc` errors in Launcher.tsx's `renderValue` (they're on main today, so the frontend build step is currently red). ## Verification Added `event_stream_tests` in src/rpc.rs (in-crate, since this is a binary crate) which serves the real `rpc_api()` stack -- GrpcWebLayer included -- over a real TCP listener and drives it with a real `EventClient`. `broadcast_reaches_client_whose_init_burst_failed` subscribes with no database available, asserts the client is *still* registered afterwards, then broadcasts and asserts it arrives. Checked against the old code: that test fails with `clients.len() == 0` (the exact eviction described above) and passes with this change. `cargo test` also passes with the default parallel harness, run repeatedly. `cargo check` / `cargo fmt --check` / `cargo clippy --all-targets -- -D warnings` / `tsc --noEmit`: all clean.
`"vite-plus": "^0.2. 9"` has a stray space, so pnpm can't match it:
[ERR_PNPM_NO_MATCHING_VERSION] No matching version found for
vite-plus@^0.2. 9 while fetching it from https://registry.npmjs.org/
The latest release of vite-plus is "0.2.9".
This fails the `setup-vp` step, so the whole frontend job dies before it
compiles anything -- main's Build check is red on this today, and so is
every PR branched off it.
One character. `pnpm install` resolves vite-plus 0.2.9 after this.
Contributor
Author
|
Heads up: the |
fix: repair the vite-plus version spec so `vp install` resolves
Contributor
Author
|
Consolidated report of everything the investigation turned up, since a few things go beyond what this PR fixes. Fixed here (#142)
Fixed in the companion PR
Blocking CI, unrelated to any of this
Found but NOT fixed — your call
Items 8–12 are each independent of this PR; happy to take any of them on if you say which you want. |
Owner
|
I try :
|
send_event_all reached no clients because the subscription was already gone by the time it ran -- not because the broadcast itself was broken. ## Root cause `sparus()`'s init task pushed `Err(Status)` into the stream on three paths (DB error from get_plugin_version, and two semver parse failures). In tonic, an `Err` item in a *server-streaming* body is not an in-band error the client can skip: `EncodeBody::poll_frame` converts it into the HTTP/2 TRAILERS frame and sets `is_end_stream = true`. The response is over. `rx` is then dropped, `tx.closed()` resolves, and the cleanup task removes the client from the registry -- a few milliseconds after it connected. Every later `send_event_all` iterates an empty map and returns `Ok(Empty)`, so the web UI reports success for a broadcast that reached nobody. The realistic trigger is `get_plugin_version`, which was called unconditionally even when the intersection was empty, so it hit the database on *every* connection. It fails whenever there's no pool configured, or the `plugins` table doesn't exist (migrations only run inside create_database, never at startup), or a stored version isn't valid semver. ## Fix - Never send `Err(Status)` on the stream channel; log and carry on. A failure to compute the *initial* diff is not a reason to drop the subscription. A bad version string now skips that one plugin instead of tearing down the whole stream. - Skip `get_plugin_version` entirely when there's nothing in common, instead of querying the DB just to get an empty map back. - Use the `EventType` enum instead of bare 1 / 2 literals. ## Also fixed, same symptom - `send_event_all` cloned nothing and awaited `send()` while holding the registry lock. One slow client (Sparus stops polling the stream while it downloads a plugin, and the channel only holds 32) blocked delivery to every other client *and* blocked `sparus()` from registering new ones. Now: clone the senders, drop the lock, `try_send`. - `send_event_all` had no logging at all, which is why this was hard to diagnose -- it returns Empty whether it reached 12 clients or 0. It now logs the delivered count, and warns when that count is zero. - The web UI offered a fourth event type, `3: "Update Frontend"`, which doesn't exist in sparus.proto (INSTALL/UPDATE/DELETE only). proto3 open enums let it through the wire, and then the launcher's `EventType::try_from(3)` fails, which ends its stream loop and unsubscribes it permanently. Clicking that button once would stop a launcher receiving anything ever again. Removed it -- if you want a frontend-update event, it needs a value in the proto enum plus handling in Sparus first. - The Broadcast button dropped the promise, so a failed RPC looked exactly like a successful one. It now surfaces the error. - Fixed two pre-existing `tsc` errors in Launcher.tsx's `renderValue` (they're on main today, so the frontend build step is currently red). ## Verification Added `event_stream_tests` in src/rpc.rs (in-crate, since this is a binary crate) which serves the real `rpc_api()` stack -- GrpcWebLayer included -- over a real TCP listener and drives it with a real `EventClient`. `broadcast_reaches_client_whose_init_burst_failed` subscribes with no database available, asserts the client is *still* registered afterwards, then broadcasts and asserts it arrives. Checked against the old code: that test fails with `clients.len() == 0` (the exact eviction described above) and passes with this change. `cargo test` also passes with the default parallel harness, run repeatedly. `cargo check` / `cargo fmt --check` / `cargo clippy --all-targets -- -D warnings` / `tsc --noEmit`: all clean.
Ludea
approved these changes
Aug 13, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This is the
send_event_all/ stream issue you described.The broadcast itself was never broken — the client was already gone by the time it ran.
Root cause
sparus()'s init task pushedErr(Status)into the stream on three paths (DB error fromget_plugin_version, and two semver parse failures).In tonic, an
Erritem in a server-streaming body is not an in-band error the client can skip.EncodeBody::poll_frame(tonic-0.14.6/src/codec/encode.rs) converts it into the HTTP/2 TRAILERS frame and setsis_end_stream = true:The response is over.
rxis then dropped,tx.closed()resolves, and the cleanup task removes the client from the registry — a few milliseconds after it connected. Every latersend_event_alliterates an empty map and returnsOk(Empty), so the web UI reports success for a broadcast that reached nobody.The realistic trigger is
get_plugin_version, which was called unconditionally even when the intersection was empty, so it hit the database on every connection. It fails when there's no pool configured, or thepluginstable doesn't exist (migrations only run insidecreate_database, never at startup), or a stored version isn't valid semver.I verified this rather than inferring it — an in-process harness serving the real
rpc_api()router with a real tonic client:I also suspected
GrpcWebLayer— it's exonerated. I reproduced the real topology (hand-built gRPC-web wire frame from the browser side + a native tonic client holding the stream simultaneously) and browser → server → native client works end to end.Fix
Err(Status)on the stream channel. Log and carry on — failing to compute the initial diff is not a reason to drop the subscription. A bad version string now skips that one plugin instead of tearing down the stream.get_plugin_versionentirely when there's nothing in common.EventTypeenum instead of bare1/2literals.Also fixed (same symptom, different causes)
send_event_allawaitedsend()while holding the registry lock. One slow client (Sparus stops polling the stream while it downloads a plugin, and the channel only holds 32) blocked delivery to every other client and blockedsparus()from registering new ones. Now: clone the senders, drop the lock,try_send.send_event_allhad no logging at all — which is a big part of why this was hard to pin down; it returnsEmptywhether it reached 12 clients or 0. It now logs the delivered count and warns when that count is zero.3: "Update Frontend", which isn't insparus.proto(INSTALL/UPDATE/DELETE only). proto3 open enums let it through the wire, then the launcher'sEventType::try_from(3)fails, ends its stream loop, and unsubscribes it permanently. Clicking that button once would stop a launcher receiving anything ever again — if that's what you tested with, it's a second, independent reproducer. Removed it; if you want a frontend-update event it needs a proto enum value plus handling in Sparus first, happy to do that.tscerrors inLauncher.tsx'srenderValue— they're onmaintoday, so the frontend build step is currently red.Verification
Added
event_stream_testsinsrc/rpc.rs(in-crate, since this is a binary crate). It serves the realrpc_api()stack —GrpcWebLayerincluded — over a real TCP listener and drives it with a realEventClient.broadcast_reaches_client_whose_init_burst_failedsubscribes with no database available, asserts the client is still registered afterwards, then broadcasts and asserts it arrives.Checked in both directions: that test fails on the current code with
clients.len() == 0(the exact eviction above) and passes with this change. Also stable under the default parallel test harness, run repeatedly.cargo check/cargo fmt --check/cargo clippy --all-targets -- -D warnings/tsc --noEmit: all clean.Related
The client half is in Ludea/Sparus —
while let Ok(Some(item))treated a stream error as a clean shutdown without even binding it, a single failed plugin download killed the whole subscription, and there was no reconnect. Separate PR on that repo.One thing I did not touch
event_type: 0(INSTALL) is unreachable insparus()— the "plugins the server knows about that the client lacks" set (registered − client) is never computed. And nothing anywhere ever INSERTs into thepluginstable (NewPluginsinmodels.rsis dead code; only a SELECT exists), so the UPDATE branch can't fire either. So the installation half of the handshake you described doesn't exist yet. That's a design decision rather than a stream bug, so I left it to you — glad to implement it if you tell me how you want plugins registered.