Skip to content

fix(mqtt): make inbound packet id register/release atomic per client (#8021) - #8075

Merged
yuluo-yx merged 2 commits into
apache:masterfrom
konlue:fix/mqtt-inflight-register-race
Oct 24, 2026
Merged

yuluo-yx merged 2 commits into
apache:masterfrom
konlue:fix/mqtt-inflight-register-race

Conversation

@konlue

@konlue konlue commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

Fixes #8021.

Summary

The follow-up to #7919 is correct: release's empty-entry cleanup did not protect against concurrent registration. The two-argument remove(clientId, packetIds) guards against the map value being replaced, not against the referenced set being mutated — a packet id registered between isEmpty() and the cleanup was evicted with the entry, so its retransmission registered as first-seen and was delivered twice. Symmetrically, register's add ran outside the computeIfAbsent boundary, so a concurrent cleanup could leave the id in a set the map had already dropped.

Modifications

  • register and release each run inside a single per-key compute: the add, the remove and the empty-entry eviction are one atomic step, and ConcurrentHashMap serializes computes on the same key — both windows are closed by construction. This is the coordination the issue suggests ("under the same per-key atomic compute"); the session-reset (remove) and PUBREL id-reuse semantics are unchanged.
  • The id map is now an instance field instead of static, matching the singleton registration (the repository instance is what every caller uses) and giving tests real isolation — the same change ChannelRepository already made.
  • Also registering in SubscribeTest the repositories subscribe() actually touches (channel/session), which the new test class's alphabetical position surfaced as an order-dependent fixture on master.

Verifying this change

  • New InflightRepositoryTest: last-id cleanup evicts the client entry; unknown id/client release is a no-op; a released id becomes reusable; session reset re-registers as first-seen; BaseRepository accessors delegate.
  • Deterministic-ish concurrency invariant: two threads loop register → assert-visible → release 100k times; against the previous implementation this fails within the first ~20 iterations (three consecutive runs: iterations 6, 1, 17 — "a freshly registered id was evicted by a concurrent release"); green with the fix, and no client entry remains at the end.
  • Full module 146/146, checkstyle:check clean.

@konlue
konlue force-pushed the fix/mqtt-inflight-register-race branch from 454e9b2 to b04b85b Compare October 24, 2026 02:42

@Aias00 Aias00 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified the fix closes both windows the issue describes.

Correctness

  • register and release now both run inside a single per-key compute, so the add and the remove+empty-eviction are mutually exclusive per client id by construction. The computeIfAbsent-returns-then-add window is gone as well, since the add no longer happens outside the map operation.
  • The compute lambda never returns null in register and returns null only when the set is empty in release, so no empty client entry can be left behind.
  • Moving the map from static to an instance field does not split state in production: MqttBootstrapServer.initRepositories() reflects over BaseRepository subtypes and registers exactly one instance per class in Singleton, which is the only path the callers use (Publish, PubRel, Connect, Disconnect). It also makes a listener restart drop stale in-flight ids, consistent with the change already made to ChannelRepository.

Verification I ran locally

  • Reverting only InflightRepository.java (new tests kept): InflightRepositoryTest fails 2/6 — concurrentRegisterAndReleaseMustNotLoseRegisteredIds at iteration 2 ("a freshly registered id was evicted by a concurrent release") and releaseOfUnknownIdOrClientIsNoOp (expected: <null> but was: <[1]>, the static map leaking state across methods). The new tests are not vacuous.
  • Reverting only the two Singleton.INST.single(...) lines in SubscribeTest: 2 NPEs (testSubAckGrantsRequestedQoS:85, testSubAckExcludesFailureSubscriptions:100). That hunk is required, not cosmetic.
  • checkstyle:check on the module: clean.

One follow-up worth a separate PR (not blocking)
The module still has a sibling of the exact fixture bug this PR fixes: MqttTransportHandlerTest registers Will/Channel/Session/Subscribe repositories but not InflightRepository, while Connect/Disconnect call Singleton.INST.get(InflightRepository.class).remove(...). Run standalone it produces 4 NPEs; in a full-module run it fails 3/9 whenever it is ordered before any class that registers InflightRepository. That is why the "146/146" figure is not reproducible on every filesystem ordering — here I get 146 run / 3 errors, and the same 3 errors appear with only the production change applied, i.e. they pre-date this PR. Since the PR already establishes the pattern in SubscribeTest, fixing that fixture in the same pass would make the module genuinely order-independent.

@Test
public void concurrentRegisterAndReleaseMustNotLoseRegisteredIds() throws Exception {
final InflightRepository shared = new InflightRepository();
final int iterations = 100_000;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

iterations is never read — publishLoop hardcodes 100_000 in its loop bound (line 126). Either pass it through as a parameter or drop the local, otherwise the two values can drift.

second.start();
start.countDown();
first.join(TimeUnit.MINUTES.toMillis(1));
second.join(TimeUnit.MINUTES.toMillis(1));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The return value of join(timeout) is ignored, and these are non-daemon threads: if the loop ever exceeds the timeout the test still passes and the threads keep running into the next test / fork shutdown. Consider asserting assertFalse(first.isAlive()) after the joins, or marking the threads daemon.

@yuluo-yx
yuluo-yx merged commit c51f3a8 into apache:master Oct 24, 2026
31 checks passed
@Aias00 Aias00 added this to the 2.7.2 milestone Nov 13, 2026
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.

[BUG] MQTT inbound inflight cleanup can remove concurrently registered QoS 2 packet IDs

3 participants