Skip to content

[Bug] MQTT QoS 1 and 2 are structurally unreachable because MqttActor never enables manualAcks, so mqtt.js PUBACKs while the message is still in the mailbox and MqttMessage.qos reports a guarantee the actor cannot provide #977

Description

@pathosDev

Problem

MqttQos documents 1 = at-least-once, 2 = exactly-once, MqttOptions.withQos(1|2) is offered and validated, and every inbound MqttMessage carries a qos field. None of that is achievable with the current wiring, because the actor never takes control of the acknowledgement.

mqtt.js acknowledges automatically: for QoS 1 it emits PUBACK, and for QoS 2 PUBREC, as soon as it has handed the packet to the 'message' listener — unless the client was constructed with manualAcks: true and the application calls client.handleMessage(packet, cb) itself. manualAcks does not appear anywhere in this repository, MqttConnectOptions has no field for it, and MqttClientLike declares no handleMessage. So there is no expressible way to defer the ack.

The framework's 'message' handler does exactly one thing: this.self.tell(...). That is a mailbox enqueue. The PUBACK is therefore on the wire while the message is still queued — and the actor mailbox is bounded drop-head by default, so it can be discarded after the broker has been told it arrived. A subscriber configured for QoS 2 gets the wire cost of exactly-once and the delivery guarantee of at-most-once.

Evidence

The claim, src/io/broker/MqttMessages.ts:10-11:

src/io/broker/MqttMessages.ts:10-11
/** MQTT QoS levels.  0 = at-most-once, 1 = at-least-once, 2 = exactly-once. */
export type MqttQos = 0 | 1 | 2;

The inbound path, src/io/broker/MqttActor.ts:455-466 — the listener only enqueues, and returns immediately, which is what lets mqtt.js ack:

src/io/broker/MqttActor.ts:455-466
        client.on('message', (topic, payload, packet) => {
          // No user code on the mqtt.js loop: wrap into a lazily-decoding
          // payload and hand the message to our own mailbox.
          this.self.tell(mqttInboundSignal<T>({
            topic,
            payload: new MqttPayload<T>(payload, this.codec(), topic),
            qos: (packet?.qos ?? 0) as MqttQos,
            retain: packet?.retain ?? false,
            userProperties: packet?.properties?.userProperties,
            reasonCode: packet?.properties?.reasonCode,
          }));
        });

The connect options the actor builds, src/io/broker/MqttActor.ts:562-571 — no manualAcks:

src/io/broker/MqttActor.ts:562-571
type MqttConnectOptions = {
  clientId?: string;
  username?: string;
  password?: string;
  clean?: boolean;
  keepalive?: number;
  /** mqtt.js: 4 (3.1.1), 5 (5.0).  We allow 4 and 5. */
  protocolVersion?: 4 | 5;
  will?: { topic: string; payload: Uint8Array | string; qos: MqttQos; retain: boolean };
};

The client surface, src/io/broker/MqttActor.ts:599-610 — no handleMessage, so even a caller who wanted to defer could not:

src/io/broker/MqttActor.ts:599-610
export interface MqttClientLike {
  on(event: 'message', cb: (topic: string, payload: Uint8Array, packet?: MqttInboundPacketLike) => void): void;
  on(event: 'error', cb: (err: Error) => void): void;
  on(event: 'close', cb: () => void): void;
  once(event: 'connect', cb: () => void): void;
  once(event: 'error', cb: (err: Error) => void): void;
  removeAllListeners(event?: string): void;
  publish(topic: string, payload: string | Uint8Array, options: MqttPubOpts, cb?: (err?: Error) => void): void;
  subscribe(topic: string, options: { qos: MqttQos }, cb?: (err?: Error) => void): void;
  unsubscribe(topic: string, options: undefined, cb?: (err?: Error) => void): void;
  end(force?: boolean, options?: object, cb?: () => void): void;
}

grep -rn "manualAcks\|handleMessage\|puback" over src/, tests/, docs/ and the .conf files returns nothing.

Proposal

Two shapes, and the choice is a product decision rather than a technical one:

  • Make QoS real. Add manualAcks to MqttConnectOptions, handleMessage to MqttClientLike, keep the packet alongside the queued signal, and call handleMessage after onMessage returns (or after an explicit ack command, matching the AmqpActor / KafkaActor vocabulary the project already has — see [Feature] Unify the broker subscribe/unsubscribe command vocabulary across broker actors #670 on unifying it). This is the only version that makes withQos(2) mean what MqttQos says.
  • Or say so. Keep auto-ack, and change MqttQos's comment plus the MQTT docs page to state that the levels describe the broker-to-client transport, and that the framework acknowledges on receipt, so end-to-end the actor is at-most-once regardless of level.

Either way, the current combination — a validated withQos(2) and a doc promising exactly-once — should not survive.

Acceptance sketch

  • Either a QoS-1 subscription does not PUBACK until the actor has processed the message, or MqttQos and the MQTT docs page state that it acks on receipt.
  • manualAcks is reachable from MqttOptions if the first branch is taken.
  • A test with a fake MqttClientLike asserts the ordering of ack versus onMessage.
  • EN and DE docs updated together.

Verification status

Found in the ten-lens production-readiness review of 2026-08-05 (v0.13.0) and re-verified before filing: confirmed by reading, plus a repository-wide grep. mqtt is not installed in this tree, so the PUBACK could not be observed on a wire; the finding does not depend on that — the option that would defer the ack has no representation in the code at all, which is a structural fact rather than a behavioural one.

Adjacent issues: #783 covers an external subscribe attaching arbitrary targets to # on the same actor; #743 covers TLS material. Neither touches QoS semantics.

Part of the production-readiness review batch — tracked in #913.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingpriority: highTop priority — high impact, plan nextproduction-goalBlocks or defines the path to production readiness

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions