Skip to content

fix(binding-mqtt): stop reserving padding once the publish stream's window is exhausted - #2545

Merged
jfallows merged 2 commits into
developfrom
fix/2521-mqtt-publish-window-exhausted-padding
Sep 4, 2026
Merged

fix(binding-mqtt): stop reserving padding once the publish stream's window is exhausted#2545
jfallows merged 2 commits into
developfrom
fix/2521-mqtt-publish-window-exhausted-padding

Conversation

@jfallows

@jfallows jfallows commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Fixes #2521

MqttPublishStream.initialBudget() returns initialMax - (initialSeq - initialAck), unlike MqttSessionStream's version which also subtracts initialPad. decodePublishPayload computed the reserved size as Math.max(publisher.initialPad, Math.min(lengthMax + initialPad, initialBudget)), which floors at initialPad even once the window can no longer hold it.

Once the granted window was exhausted, this kept emitting zero-length DATA frames that still reserved initialPad bytes, advancing initialSeq with no decode progress. Every subsequent WINDOW re-entered the same path and burned another initialPad, walking initialSeq past initialAck + initialMax. The downstream peer eventually sees sequence > acknowledge + maximum and RESETs, which propagates to onDecodeError with a reason code the MQTT v3.1.1 CONNACK encoding can't carry, so doNetworkEnd tears down the connection with no message sent to the client at all — the client just sees the TCP/TLS connection disappear.

The threshold is bytes, not messages: it trips as soon as cumulative payload + padding first exhausts the granted initial window, so message count before the cutoff scales inversely with payload size.

Fix computes the payload cap as Math.min(lengthMax, initialBudget - initialPad) and only proceeds once it and the resulting claimed size are non-negative, so once the window is exhausted no frame is emitted and no forward progress is recorded — the stream just waits for the next WINDOW like every other backpressured decode path in this file.

Credit to community contributor @sfr-oc, who diagnosed and fixed this as part of the combined #2523. This PR cherry-picks that fix's commit standalone (unmodified, 01745473), since #2523 bundles seven independently-scoped defects across three bindings into one PR — each deserves its own focused review, and this one is ready on its own.

Test coverage

Includes the original commit's coverage, plus a naming-consistency rename in a separate commit: the new scenario is publish.multiple.messages.100c (originally publish.many.messages), matching this suite's existing publish.multiple.messages family (.disconnect, .unfragmented, .with.delay) rather than introducing new vocabulary. The 100c suffix establishes a "count" convention (distinct from the suite-wide "size" convention, e.g. publish.10k) for reuse in future scenarios that vary message count rather than payload size.

  • New paired k3po scripts (client.rpt + server.rpt) in both specs/binding-mqtt.spec's application/ and network/v4/ trees
  • Spec-level self-consistency IT for each pair
  • Runtime IT against a live engine (runtime/binding-mqtt's server/v4/PublishIT#shouldPublishMultipleMessages100c)

Verification

  • Reverted just the production fix and confirmed the IT fails for the right reason: a hard crash (AssertionError in doPublishData, then resource-leak and protocol-frame-corruption errors on retry) — matching the described sequence-walks-past-window mechanism — then restored the fix and confirmed the test passes
  • Confirmed this isn't redundant with the existing publish.10k scenario: ran that test unmodified against the pre-fix code and it passes, since the defect requires many small messages landing on a specific cumulative-budget boundary, not simply a large single payload
  • Full reactor build compiles clean

🤖 Generated with Claude Code

https://claude.ai/code/session_015YVNaqKvEXZVzmg3HoGVnt


Generated by Claude Code

sfr-oc and others added 2 commits September 4, 2026 21:44
…indow is exhausted

MqttPublishStream.initialBudget() returns initialMax - (initialSeq -
initialAck), unlike MqttSessionStream's version which also subtracts
initialPad. decodePublishPayload computed the reserved size as
Math.max(publisher.initialPad, Math.min(lengthMax + initialPad, initialBudget)),
which floors at initialPad even once the window can no longer hold it.

Once the granted window is exhausted, this kept emitting zero-length DATA
frames that still reserved initialPad bytes, advancing initialSeq with no
decode progress. Every subsequent WINDOW re-entered the same path and burned
another initialPad, walking initialSeq past initialAck + initialMax. The
downstream peer eventually sees sequence > acknowledge + maximum and RESETs,
which propagates to onDecodeError with a reason code the MQTT v3.1.1 CONNACK
encoding can't carry (reasonCode > MAX_CONNACK_REASONCODE_V4), so
doNetworkEnd tears down the connection with no message sent to the client at
all - the client just sees the TCP/TLS connection disappear.

The threshold is bytes, not messages: it trips as soon as cumulative
payload + padding first exhausts the granted initial window, so message
count before the cutoff scales inversely with payload size - independent of
any will-message handling.

Compute the payload cap as Math.min(lengthMax, initialBudget - initialPad)
and only proceed once it and the resulting claimed size are non-negative, so
once the window is exhausted no frame is emitted and no forward progress is
recorded - the stream just waits for the next WINDOW like every other
backpressured decode path in this file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Renamed the cherry-picked publish.many.messages scenario to
publish.multiple.messages.100c, matching this suite's existing
publish.multiple.messages family (.disconnect, .unfragmented,
.with.delay) rather than introducing "many" as new vocabulary.

The suffix introduces a "count" convention (Nc) distinct from this
suite's established "size" convention (Nk, e.g. publish.10k,
session.will.message.32k) -- a bare number would read as a byte size
given that precedent holds without exception across the whole specs
tree. Verified the existing publish.10k scenario does not itself
already exercise this defect (passes unmodified on pre-fix code), so
this is not a redundant scenario: the bug requires many small
messages landing on a specific cumulative-budget boundary, not
simply a large payload.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YVNaqKvEXZVzmg3HoGVnt
@jfallows
jfallows merged commit 077b5d5 into develop Sep 4, 2026
43 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.

PUBLISH payload decode reserves padding after the publish stream window is exhausted

3 participants