Skip to content

MQTT: send each client the CONNACK refusal code of its own protocol version - #3402

Merged
kriszyp merged 5 commits into
mainfrom
fix/mqtt-connack-reason-codes
Oct 26, 2026
Merged

kriszyp merged 5 commits into
mainfrom
fix/mqtt-connack-reason-codes

Conversation

@kriszyp

@kriszyp kriszyp commented Oct 21, 2026 •

Copy link
Copy Markdown
Member
A sweating engineer at a console with two buttons, one for each protocol version, and a refusal slip in the wrong slot

⊙ Problem

mqtt-packet sends reasonCode to a v5 client and returnCode to a 3.1.1 client. The CONNACK refusals in server/mqtt.ts had the two swapped, so a v5 client with a wrong password was told 4 and a 3.1.1 client 0x86. An error.code thrown during connect, such as an HTTP status, was also written as is. #3303 fixed SUBACK, PUBACK and DISCONNECT, not CONNACK. Refs #3298.

🤔 Is this the right problem?

Yes. Clients act on this code: a v5 client that gets 4 cannot tell bad credentials from a protocol error.

💡 Solution

  • Every refusal now carries both codes (CONNACK_REFUSALS): bad credentials 0x86 / 4, not authorized 0x87 / 5, anything else 0x80 / 3.
  • A throw from authorizeClient (the usual way a hook refuses) defaults to "not authorized" (its own catch); other connect failures are "unspecified".
  • A numeric error.code is sent as is only if the client's version allows it in a CONNACK (connackRefusalFor). Otherwise 401 or 403 (statusCode or code) is "not authorized", and the rest takes the default. The refusal sites now use these (bad credentials, session errors).

🤔 Unspecified is 3 for 3.1.1

3.1.1 has no generic refusal. For a session or storage failure, 5 would suggest a credentials problem. 3 ("server unavailable") is what a session takeover timeout already sends. A hook refusal is a denial, so it keeps 5.

🤔 error.mqttReasonCode is not read at CONNACK

Only the inbound QoS 2 path sets it, and never during CONNECT. Reading it too would add a second way to name a code with no caller.

✅ Verification

MQTT implementation validation from the authoring and rebase runs
  • Unit: 41 cases in mqttNotAuthorized.test.js. 17 fail on origin/main (built side by side), all CONNACK cases, and all pass here.
  • End to end: 4 new CONNACK cases in mqtt-not-authorized.test.ts over a real WebSocket client, using a fixture hook that refuses client ids starting denied- with a 403 (config); all 4 fail on origin/main (actual: 4, expected: 134). integrationTests/mqtt/**: 59 pass.
  • unitTests/server/**: 1451 pass before the rebase; the three MQTT unit files pass after it. tsc, oxlint and prettier are clean.
  • test:unit:main hit its 50 minute limit on this host before finishing. The 47 failures it had logged are in deploy-activation and EntryHandler suites, none touching MQTT. The MQTT unit files and the rest of unitTests/server/** pass.
  • PR QA: skipped @ 50111f6 — no worker token available to queue it.

PR QA: skipped @ ed89e94 — documentation-only maintenance.

🤔 Why include the design-doc trim?

It is identical to merged main #3429. On this branch the linter fails at 1,014 lines. The separate trim commit fixes CI and keeps the PR's history. The 1,000-line gate remains intact.

⚖️ Alternatives

🤔 Keep the existing hook policy

Numeric hook codes stay version-specific, as the explicit tests require. Translating them across versions would change that policy. The cost of one refusal closure per CONNECT is unmeasured. This lint repair keeps that existing behavior. Post-hook 401/403 coverage and comment cleanup remain follow-ups. The async-hook and socket-close deferrals below remain separate.

🤔 Keep the published CONNECT handler

A single CONNECT catch with a hook-stage fallback could replace the shared handler. Both can preserve refusal semantics. This maintenance commit only restores the doc budget, so it retains the published runtime implementation.

🤖 Generated by Anthropic Claude and OpenAI Codex; posted via @kriszyp.

https://claude.ai/code/session_01BeYyDXfTjDKHwpzUXyTHDZ

Release target: v5.3 — fixes code from #3303, a backport to v5.3
Related PRs: #3446 overlaps, #3464 overlaps, #3429 overlaps, #3430 overlaps, #3221 overlaps, #3456 overlaps, #3423 overlaps, 5 others independent
Complexity: medium

Dispatch: task harper-mqtt-connack-reason-codes · queued by unknown · ran by claude/sonnet/high · worker kzyp-xps-1

Deferred findings

  • b9c349471e41a5f6 minor security — server/mqtt.ts — Async authorizeClient rejection can admit CONNECT — NOT FILED
  • 55167bb87455efd4 minor correctness — server/mqtt.ts — Refusal CONNACK leaves the connection open — NOT FILED

Review-Coverage: authored=codex; ran=gemini,claude,cursor-grok; adjudicated=domain; declined=cursor-composer,muse; rounds=6; full=2 @ ed89e94

Review-Attention: study ~9m (hot: mqtt.ts; decisions: hook-fault-policy, do-less-alternative, bare-hook-error-is-denial, generic-legacy-refusal) @ ed89e94

@kriszyp kriszyp added this to the v5.3 milestone Oct 21, 2026
@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Release cherry-pick v5.3: conflict

Cherry-pick onto v5.3 produced conflicts on commit(s): ed89e94ce33d99570d7da5d0a9999a22d7f198da

The conflict markers are committed on branch cherry-pick/v5.3/pr-3402.
A pull request has been opened to land this patch: #3555

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request updates the MQTT server implementation to correctly return protocol-specific connection refusal codes (reason codes for MQTT v5 and return codes for MQTT v3.1.1) during connection establishment (CONNACK). It introduces helper utilities to map internal errors to appropriate protocol codes and adds comprehensive integration and unit tests to verify these behaviors. There are no review comments, and I have no feedback to provide.

@kriszyp

kriszyp commented Oct 22, 2026

Copy link
Copy Markdown
Member Author

Suggestion: add a read-only-node arm to the CONNACK test.

On main c12239b4c, a read-only refusal reaches the CONNECT catch (server/mqtt.ts:536-550) with the string error.code === 'ERR_NOT_SUPPORTED', which mqtt-packet cannot encode (Invalid return code), so the client sees a DISCONNECT and a timeout. With read-only 405 refusals (#3427) the error has no .code, so the current fallback sends 5 on v5 (a v3.1.1 code reused) and 128 on v3.1.1. connackRefusalFor as described here should cover both, since it sends a numeric code only if valid for that version and otherwise 0x80 (v5) or 3 (v3.1.1). A read-only arm (v5 and v3.1.1 clients, both the string-code and the code-less error) would pin that.

The residual behavior, durable sessions (clean: false, QoS above 0) failing to subscribe or resume on a read-only node, is a separate design question tracked in see #3460.

— Claude Opus 5.5 (finding triage)

@kriszyp
kriszyp marked this pull request as ready for review October 23, 2026 02:47
kriszyp and others added 3 commits October 22, 2026 21:12
…rsion

mqtt-packet writes reasonCode to a v5 client and returnCode to a 3.1.1
client, but the refusal sites had the two swapped, so a v5 client got the
3.1.1 code and the reverse. Bad credentials are now 0x86 / 4, not
authorized 0x87 / 5, unspecified 0x80 / 5. An authorizeClient error whose
numeric code is an HTTP status is mapped to Not authorized instead of being
written to the wire; a code is passed on only if it is a refusal code in the
client's own version.

Refs #3298

Dispatch-Task: harper-mqtt-connack-reason-codes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BeYyDXfTjDKHwpzUXyTHDZ
…d pass on only CONNACK-legal v5 codes

Dispatch-Task: harper-mqtt-connack-reason-codes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BeYyDXfTjDKHwpzUXyTHDZ
…r fault

A hook refuses a CONNECT by throwing, usually a plain Error. That reached a
3.1.1 client as return code 3 (server unavailable), which clients retry, for
what is a permanent denial. The hook now has its own catch that defaults to
Not authorized; storage and takeover failures keep the unspecified code.

Dispatch-Task: pr-maint-3d73ce37e83a10641219417272e78bdf
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@kriszyp
kriszyp force-pushed the fix/mqtt-connack-reason-codes branch from c39d665 to 50111f6 Compare October 23, 2026 03:39
Apply the resources/DESIGN.md prose-only trim from main commit 489eebc (#3429), reducing the note from 1014 to 998 lines so the design-doc gate passes on this PR without rebasing or changing runtime behavior.

Dispatch-Task: pr-maint-3d73ce37e83a10641219417272e78bdf

Co-Authored-By: GPT-5 Codex <noreply@openai.com>
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.

1 participant