messaging: rapid send-button clicks silently drop messages #183
Labels
No labels
prio_critical
prio_low
type_bug
type_contact
type_issue
type_lead
type_question
type_story
type_task
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
lhumina_code/hero_archipelagos#183
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
Firing 10 sequential clicks on
.send-btnfrom the same chat persists 0 messages — the user sees no error and no failed-status indicator on any bubble. Effectively a silent-drop race.Repro
In an open DM, evaluate in DevTools:
Reload the page → 0 of 10
rapid#Nmessages persisted.Where
archipelagos/messaging/src/islands/chat_input.rs—handle_sendreads the localinput_valuesignal and callsprops.on_send.call(value).archipelagos/messaging/src/archipelago.rs:281-322—handle_sendspawns an async send per click;sending: Signal<bool>is set/unset but never gates new sends. Concurrent in-flightsend_messagefutures race on the same client.Related (not duplicate)
Closed #48 fixed rapid chat-switch races (different scenario). The send-while-sending case is different and unguarded.
Suggested fix
At the start of
handle_sendinarchipelago.rs:if *sending.peek() { return; }(and re-enable on completion). Also passdisabled: sending()intoChatInputso the button reflects state. The button's existing disabled-class wiring needs the DOM-level fix in the sibling Button-component issue.Found via QA session 2026-04-29.
Implementation Spec — Issue #183: Rapid send-button clicks silently drop messages
Objective
Prevent concurrent
send_messageRPCs from racing when the user fires repeated clicks on the send button (or rapid Enter presses) by re-entrancy-guardinghandle_sendsynchronously, before any task is spawned.Background (current state, post PR #210)
archipelagos/messaging/src/archipelago.rs:504-564—handle_sendcurrentlyspawns an async task on every call.sending.set(true)andsending.set(false)already exist (lines 511 and 561) but are inside the spawned future, so all rapid clicks complete their synchronous portion (clone +spawn) before any future runs to flipsending. Result: N concurrentsend_messagefutures share oneCommunicationClientand most resolutions are silently lost.archipelagos/messaging/src/islands/chat_input.rs:7-15—ChatInputPropsalready haspub disabled: bool(line 11). Wired to the inner input (disabled: props.disabledon the<input>, line 76) and to the send Button (disabled: props.disabled || input_value().trim().is_empty(), line 85).archipelagos/messaging/src/islands/chat_view.rs:39-40,109-116—ChatViewProps.sending: boolalready exists and is forwarded asChatInput { disabled: props.sending, ... }.archipelagos/messaging/src/archipelago.rs:728-731and:786-789— both layout branches (Desktop/Full and Mobile) already passsending: *sending.read()intoChatView, so reactivity from thesendingsignal to the disabled-state already works.core/src/components/button.rs:117-166—Buttonuses a CSS-class disabled style (hero-btn-disabled) and an in-handler click-guard (if !is_disabled { props.onclick.call(e) }). Crucially, it does NOT set the HTMLdisabledattribute. The click-guard only fires after the parent has actually re-rendered withdisabled = true. Confirms the bug: the parent'ssendingsignal is not flipped synchronously inside the click handler, so consecutive clicks all seeis_disabled == false.chat_input.rs:28and:38. Enter-to-send (line 35) routes through the sameprops.on_send, so any parent-level gate onon_sendautomatically covers Enter.Requirements (post-fix behaviors)
.send-btnclicks (or 10 Enter presses) from one chat invokeservices::send_messageexactly once for the first call; subsequent calls during in-flight are dropped at the parent beforespawn.sendingresets in both branches of the existing match.hero-btn-disabledCSS class whilesending == true.sending == true(already wired viaprops.disabledon<input>).on_send).own_message_idsandservices::save_own_message_ids.Files to modify
archipelagos/messaging/src/archipelago.rs— add a synchronous re-entrancy guard at the top ofhandle_sendand flipsendingtotruesynchronously beforespawn.No new files. No prop signature changes. No call-site changes —
ChatView/ChatInput/Buttonall already accept and propagate the disabled bool.Step-by-step implementation plan
Step 1 — Add synchronous re-entrancy guard in
handle_sendFile:
archipelagos/messaging/src/archipelago.rs(currenthandle_sendat lines 504-564).Change the body of the
move |content: String|closure so that:route, before anyclones, beforespawn— isif *sending.peek() { return; }.if let Route::Chat { sid, .. } = current_routematch succeeds (so we don't latchsendingwhen the route isn't a chat), callsending.set(true)synchronously, outside thespawnblock.sending.set(true)from inside the spawned future (currently line 511). Keep the existingsending.set(false)at the end of the future (line 561) — it correctly covers both Ok and Err paths because it's after thematch.Why:
peek()reads the signal without subscribing the closure to it (no spurious re-renders), and the synchronousset(true)ensures the second click within the same event-loop tick observestrueand bails. The async end-of-taskset(false)already handles re-enabling on both success and failure.Resulting shape (illustrative):
if *sending.peek() { return; }at the top of the closure.if let Route::Chat { sid, .. } = current_route { sending.set(true); spawn(async move { ... sending.set(false); }); }.Dependencies: none.
Step 2 — Verify reactivity path is intact
No code change. Confirm by reading:
archipelago.rs:728-731and:786-789— both passsending: *sending.read()toChatView. The*sending.read()subscribes the parent's render to the signal, so the prop value flows on each flip.chat_view.rs:109-116—ChatInput { disabled: props.sending, ... }.chat_input.rs:76,85— both the input and the send Button are gated onprops.disabled.core/src/components/button.rs:120,138-142—is_disabled = props.disabled || props.loading, click-guard insideonclick.Result: once
sending.set(true)runs synchronously in the parent, the next click within the same event tick is blocked by the parent guard; the subsequent re-render disables the button and input visually. Both rails are covered.Acceptance criteria
rapid#Nmessage persists (typicallyrapid#0). No silent drops.hero-btn-disabledstyle and the input is disabled until the in-flightsend_messageresolves.sendingresets tofalse, the error banner appears, and a subsequent send is again possible.own_message_idslocalStorage entry written by the successful send (PR #210 path) contains the one accepted message id and no orphans for the dropped clicks.Notes / caveats
own_message_ids.with_mut(...)andservices::save_own_message_idsruns once per accepted send — unaffected by the gate, since the gate only blocks beforespawn.Button's click-guard only sees the prop value at render time. Since the parent does not flipsendinguntil inside the spawned future, the rendered Button still hasis_disabled=falsefor the entire synchronous burst. The fix must be at the parent because that is where the signal lives and where the synchronous transition has to occur.disabledtoSignal<bool>: the codebase convention is plainbool(seeButtonProps.disabled: bool,ChatInputProps.disabled: bool,ChatViewProps.sending: bool). Reactivity is achieved by reading the signal at the parent's render (*sending.read()), which subscribes the parent's component to re-render. No prop-shape change is required.disabledissue alluded to in the issue (re-introducing a real HTMLdisabledattribute or otherwise hardening Button against synthetic.click()calls during the brief tick before re-render) is intentionally not addressed here. The parent-level guard is the correct and sufficient fix for #183.chat_input.rs:34-43callsprops.on_send.call(value)— the sameon_sendthat the parent gates. No separate change needed.Test Results
Crate:
hero_archipelagos_messaging(cargo test --lib)Tests run
These are the tests landed by PR #210; this fix is a pure structural change to
handle_send(liftsending.set(true)out of the spawned future and add a synchronousif *sending.peek() { return; }at the top) and does not change any pure function under test, so no new unit tests were added.Builds
cargo check -p hero_archipelagos_messaging: clean.cargo check -p hero_archipelagos_messaging --target wasm32-unknown-unknown: clean.cargo check --workspace --exclude hero_archipelagos_messaging: clean (9unused_mutwarnings inserver/src/main.rsare pre-existing ondevelopmentand unrelated to this fix).Why no new unit tests
The race is across event-loop ticks: the first click's synchronous closure body runs, then control returns to the runtime, then the spawned future is polled and starts the RPC. Subsequent clicks fire their synchronous body before the future has had a chance to flip
sending. Reproducing this in acargo testwould require a Dioxus runtime harness with a controllable executor — a unit test againstSignal::set/peekadds no signal beyond what the type's existing tests already cover.A live browser repro using the issue's DevTools snippet is the right verification rail. Live-test results to follow in the implementation summary.
Implementation Summary
Branch:
development_fix_send_reentrancy_raceApproach
Added a single synchronous re-entrancy guard at the top of
handle_sendinarchipelagos/messaging/src/archipelago.rs, and lifted the existingsending.set(true)out of the spawned future so it runs synchronously beforespawn. The bug was thatsending.set(true)ran inside the spawned async block, so 10 rapid clicks all completed their synchronous closure body (clone +spawn) before any future was polled — N concurrentsend_messagefutures then raced on the sameCommunicationClientand most resolutions were silently dropped.The fix runs entirely at the parent.
Button's in-handler click-guard cannot rescue this case because it only sees the prop value at render time, and the re-render does not happen until after the parent flipssending— which the buggy code only did inside the future.Files changed
archipelagos/messaging/src/archipelago.rs—handle_sendclosure:if *sending.peek() { return; }at the top;sending.set(true)moved synchronously beforespawn(inside theif let Route::Chat { sid, .. }branch); the originalsending.set(false)at the end of the future is unchanged and continues to cover both Ok and Err.Why no new unit tests
The race is across event-loop ticks (synchronous closure body vs. spawned future polling). Reproducing it in a
cargo testwould require a Dioxus runtime harness with a controllable executor; a unit test againstSignal::set/Signal::peekwould just retest the type's existing semantics. Live browser repro using the issue's DevTools snippet is the right verification rail — see below.Verification
cargo test -p hero_archipelagos_messaging --lib: 5/5 passed (PR #210 tests still green).cargo check -p hero_archipelagos_messaging: clean.cargo check -p hero_archipelagos_messaging --target wasm32-unknown-unknown: clean.cargo check --workspace --exclude hero_archipelagos_messaging: clean (9unused_mutwarnings inserver/src/main.rsare pre-existing ondevelopmentand unrelated).make run->http://localhost:8886-> messaging DM): pasted the issue's 10-click DevTools snippet, observed exactly 1 message bubble appear, F5, re-opened the chat, exactly 1 of 10rapid#Nmessages persisted (vs 0 before the fix). Send button reflected the disabled state during the in-flight RPC. Subsequent single send worked normally.Notes
own_message_idsinsert +services::save_own_message_idscalls (PR #210 path inside the spawned future) are unchanged and run exactly once for the accepted send. No orphan SIDs from dropped clicks because the guard returns before any state mutation.chat_input.rsroutes Enter through the sameprops.on_send, which is the gatedhandle_send.Out of scope
disabledattribute or otherwise hardening Button against synthetic.click()calls during the brief tick before re-render) is not addressed here. The parent-level guard is sufficient and correct for #183. The Button hardening, if desired, should be tracked under that sibling issue.Pull request opened: #211
This PR implements the changes discussed in this issue.