Skip to content

fix: keep the client subscribed when the initial plugin diff fails - #142

Merged
Ludea merged 8 commits into
Ludea:mainfrom
gloubix:fix/event-stream-broadcast
Aug 13, 2026
Merged

fix: keep the client subscribed when the initial plugin diff fails#142
Ludea merged 8 commits into
Ludea:mainfrom
gloubix:fix/event-stream-broadcast

Conversation

@gloubix

@gloubix gloubix commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

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 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 (tonic-0.14.6/src/codec/encode.rs) converts it into the HTTP/2 TRAILERS frame and sets is_end_stream = true:

Some(Err(status)) => match self_proj.state.role {
    Role::Client => Some(Err(status)).into(),
    Role::Server => {
        self_proj.state.is_end_stream = true;
        Some(Ok(Frame::trailers(status.to_header_map()?))).into()
    }
},

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 when 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.

I verified this rather than inferring it — an in-process harness serving the real rpc_api() router with a real tonic client:

[probe] clients.len() = 1
[probe] first stream item = Err(code=Internal, msg="Cannot get Pool")
[probe] after the Err(Status) propagated, clients.len() = 0

I also suspected GrpcWebLayerit'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

  • Never send 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.
  • Skip get_plugin_version entirely when there's nothing in common.
  • Use the EventType enum instead of bare 1 / 2 literals.

Also fixed (same symptom, different causes)

  • send_event_all 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 a big part of why this was hard to pin down; 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 3: "Update Frontend", which isn't in sparus.proto (INSTALL/UPDATE/DELETE only). proto3 open enums let it through the wire, then the launcher's EventType::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.
  • The Broadcast button dropped the promise, so a failed RPC looked identical to a success. 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). It 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 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 in sparus() — the "plugins the server knows about that the client lacks" set (registered − client) is never computed. And nothing anywhere ever INSERTs into the plugins table (NewPlugins in models.rs is 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.

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.
@gloubix

gloubix commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

Heads up: the frontend check here is red for a reason unrelated to this PR. web/package.json has "vite-plus": "^0.2. 9" (stray space), so vp install can't resolve it and the job dies before compiling anything. That's on main today — its own Build check is failing the same way. Opened #143 with the one-character fix; once that merges I'll rebase this and the check should go green.

fix: repair the vite-plus version spec so `vp install` resolves
@gloubix

gloubix commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

Consolidated report of everything the investigation turned up, since a few things go beyond what this PR fixes.

Fixed here (#142)

  1. The root causeErr(Status) in the init task ended the stream (TRAILERS), evicting the client from the registry within milliseconds.
  2. send_event_all awaited send() while holding the registry lock — one slow client blocked delivery to all others and blocked new registrations.
  3. send_event_all had no logging, so a broadcast reaching 0 clients looked identical to one reaching 12.
  4. The web UI's event_type: 3 ("Update Frontend"), absent from the proto — an independent reproducer of the same symptom.
  5. Broadcast button swallowed its promise rejection; two pre-existing tsc errors in Launcher.tsx.

Fixed in the companion PR

  1. fix(rpc): survive stream errors instead of unsubscribing silently Sparus#1058 — the launcher treated a stream error as a clean shutdown without binding the error, a single failed plugin download ended the whole subscription, and there was no reconnect.

Blocking CI, unrelated to any of this

  1. fix: repair the vite-plus version spec so vp install resolves #143"vite-plus": "^0.2. 9" (stray space) breaks vp install. main's Build is red on this today.

Found but NOT fixed — your call

  1. An unknown repository_name makes the server tell the launcher to delete all its plugins #144 (destructive) — an unknown repository_name makes the server send DELETE for every plugin the client has, and the launcher runs remove_dir_all on each. Reachable in practice, see 9.
  2. cms_url is not configurable: the store key it reads is never written Sparus#1059cms_url reads a store key (launcher_url) that nothing anywhere writes, so it's always http://127.0.0.1:8112; and dispatch.yml truncates its config file five times over, discarding launcher_name (hence "kataster"). Worth checking before you judge whether this PR fixed things for you: if you tested with a CI-built launcher, it was subscribing to localhost while the web UI broadcast elsewhere — different process, different registry, so nothing would arrive regardless of the stream code.
  3. INSTALL is unreachableregistered − client is never computed, and nothing ever INSERTs into the plugins table (NewPlugins is dead code; only a SELECT exists), so the UPDATE branch can't fire either. The "installation" half of the handshake you described doesn't exist yet.
  4. No h2 keepalive on the launcher's subscription (Endpoint defaults: no http2_keep_alive_interval, keep_alive_while_idle = false). A stream idle between startup and your click can be reaped by NAT / the Tailscale funnel; with #1058 it now reconnects instead of dying, but enabling keepalive would avoid the churn.
  5. tonic-web returns a bare 400 for native application/grpc over HTTP/1.1 (its RequestKind::Other(non-H2) arm). Not a problem today since Sparus connects directly over h2c — but it would bite if native clients are ever put behind a proxy that downgrades to HTTP/1.1.

Items 8–12 are each independent of this PR; happy to take any of them on if you say which you want.

@Ludea

Ludea commented Aug 13, 2026

Copy link
Copy Markdown
Owner

I try :

  • start lucle with this PR ( I get Cannot get Pool only on my local dev env, I don't get this issue on prod)
  • use http://127.0.0.1:8112 instead of cms_url variable (it's only for dev purpose)
  • add some logs to print event message sent by lucle to Sparus for debugging
  • start Sparus with fix(rpc): survive stream errors instead of unsubscribing silently Sparus#1058
  • reach lucle webui
  • send an event with webui with send_event_all
    There are no logs when sending event from webui. But I can see, sparus is connected to lucle with rpc stream

Ludea and others added 5 commits August 13, 2026 14:06
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
Ludea merged commit c76860d into Ludea:main Aug 13, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants