Skip to content

qt: answer a rate-limited frame instead of dropping it - #261

Merged
Yaraslaut merged 4 commits into
masterfrom
fix/225-rate-limit-reply
Aug 24, 2026
Merged

qt: answer a rate-limited frame instead of dropping it#261
Yaraslaut merged 4 commits into
masterfrom
fix/225-rate-limit-reply

Conversation

@Yaraslaut

Copy link
Copy Markdown
Member

Closes #225.

The defect

QtWebSocketServerConfig::messagesPerSecond dropped an over-budget frame
silently — not replied to, not queued, connection left open. From the
caller's side that was an execute whose Completion simply never resolved,
unless Bridge::setExecuteDeadline was armed (off by default).

The drop itself is right: refusing is what a rate limiter does, and closing the
connection would turn a transient burst into an eviction. What was wrong is that
the frame went unanswered. A reply costs nothing at the protocol level and is
the difference between a caller's Completion failing and it hanging forever.

The fix

An over-budget frame is answered with err "rate limited", addressed via
peekCallId's bounded prefix scan — the same reply-without-full-decode pattern
the maxMessageBytes branch a few lines above already used, now hoisted so both
branches share it. The frame still never reaches RemoteServer, and the
connection still stays open.

Addressing matters as much as replying. A zeroed callId would be worse than
no reply at all: callId == 0 is the client's synchronous-reply discriminator, so
it would resume some unrelated parked register/deregister while the execute
that triggered it still hung. The test asserts every refusal carries the callId
of the frame it refuses.

The existing test had to change, and got stronger

The throttle test asserted the absence of replies (replies < 20) — which this
change necessarily invalidates. I rewrote it to distinguish outcomes rather than
count frames:

  • ok replies and rate limited replies counted separately
  • the throttle still throttles (ok < 20)
  • nothing is left unanswered (ok + limited == 20) — the assertion that fails
    if the silent drop ever comes back
  • every refusal's callId is one of the sent ids
  • the connection is still ConnectedState afterwards

Mutation-verified: restoring the silent drop fails it with 0 >= 1.

Docs

Four places described the hang as expected behaviour and are corrected: the
header, backend.md's config and design-decision tables, and the completion.md
passage listing a rate-limited frame among the requests that "genuinely
disappear" — it no longer belongs there. A deadline is still worth arming for a
dropped connection or a hung server, which no reply can cover, and the text now
says exactly that. test_client_execute_deadline.cpp's header comment made the
same claim and is fixed.

Verification

check result
morph_qt_tests 535 assertions in 72 cases, all passed
morph_tests 20658 assertions in 1170 cases, all passed
[limits] cases 94 assertions in 11 cases
clang-tidy-diff (clang 22, as CI pins) clean
-Wdocumentation -Werror clean
scripts/check_spec_citations.sh pass

The last two were run locally because #258 showed CI's clang-tidy-diff and the
WASM job's -Weverything catch things an ordinary local build does not.

QtWebSocketServerConfig::messagesPerSecond dropped an over-budget frame
silently -- not replied to, not queued, connection left open. From the caller's
side that was an `execute` whose Completion simply never resolved, unless
Bridge::setExecuteDeadline was armed, which is off by default.

The drop itself is right: refusing is what a rate limiter does, and closing the
connection would turn a transient burst into an eviction. What was wrong is that
the frame went unanswered. A reply costs nothing at the protocol level and is the
difference between a caller's Completion failing and it hanging forever.

An over-budget frame is now answered with `err "rate limited"`, addressed via
peekCallId's bounded prefix scan -- the same reply-without-full-decode pattern
the maxMessageBytes branch a few lines above already used, now hoisted so both
branches share it. The frame still never reaches RemoteServer, and the
connection still stays open.

Addressing matters as much as replying: a zeroed callId would be worse than no
reply at all, since callId == 0 is the client's synchronous-reply discriminator
and would resume some unrelated parked register/deregister while the execute
that triggered it still hung. The test asserts every refusal carries the callId
of the frame it refuses.

The existing throttle test asserted the *absence* of replies (replies < 20),
which this change necessarily invalidates. Rewritten to distinguish outcomes
rather than count frames: ok replies and rate-limited replies are counted
separately, the throttle is still shown to throttle (ok < 20), and nothing is
left unanswered (ok + limited == 20) -- the assertion that fails if the silent
drop ever comes back. Verified by mutation: restoring the drop fails it with
`0 >= 1`.

Docs updated where they described the hang as expected: the header, the
backend.md config and design-decision tables, and the completion.md passage
listing a rate-limited frame among the requests that "genuinely disappear" -- it
no longer belongs there, though a deadline is still worth arming for a dropped
connection or a hung server, which no reply can cover. The
test_client_execute_deadline.cpp header comment said the same and is corrected.

qt suite 535 assertions / 72 cases; framework 20658 / 1170; clang-tidy-diff and
-Wdocumentation clean.

Closes #225
The original run was cancelled by the supersede-obsolete-runs concurrency rule
(#257) shortly after it merged, and re-running the cancelled workflows produced
attempts that were themselves cancelled within minutes. An empty commit gives
the PR a fresh head so its checks run from a clean slate.

No content change.
examples/TESTING.md and examples/polls/README.md each list the client-side
execute deadline as a framework gap and cite a rate-limited frame being 'dropped
silently' as one of the two things that make a Completion hang. That half is no
longer true once the transport answers the frame.

Narrowed to the clause this change invalidates. Both lists are stale in a second
way that is not mine to fix here -- they name the execute deadline and
Bridge::pendingCalls() as missing framework capabilities, and both shipped
(bridge.hpp:1099 and bridge.hpp:1166). Filed separately rather than folded in.
CI caught what I missed: examples/polls/tests/test_shared_instance_lifecycle.cpp
has an end-to-end case built on the silent drop. It bursts 20 SubmitVotes past a
messagesPerSecond=5 bucket and asserted `clientTimeouts >= 1` -- that at least
one call was recovered by setExecuteDeadline, which was the only thing that
could settle a frame nobody answered. Now the transport answers it, the deadline
never fires, and the assertion fails.

I had claimed no ladder test configured the limiter. That was wrong, and wrong
for an avoidable reason: I grepped examples/ for messagesPerSecond and read the
first five hits, which were all documentation. The sixth was this test.

Rewritten for the contract the change establishes rather than deleted, since
what it proves is still worth proving -- the rung DoD asks for this harness run
with messagesPerSecond ON:

  - it now asserts at least one call settled with the transport's own
    `err "rate limited"`, and that successes < calls sent, keeping the
    "more calls than replies" proof the brief asks for;
  - and it asserts clientTimeouts == 0. The deadline is still armed, and is
    deliberately no longer what saves these calls: a timeout here would now mean
    a call really did go unanswered, which is the regression this guards.

The file's header bullet and the two long setup comments describing "dropped
silently, no reply of any kind" are corrected with it.

polls ladder suite: 68/68.
@codecov

codecov Bot commented Aug 24, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@Yaraslaut
Yaraslaut merged commit f7c09b4 into master Aug 24, 2026
35 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.

A rate-limited frame on the Qt WebSocket transport is dropped with no reply, hanging the caller's Completion

1 participant