Skip to content

feat(Push): add Appwrite Push (MQTT 5) adapter - #129

Open
deepshekhardas wants to merge 1 commit into
utopia-php:mainfrom
deepshekhardas:fix/122-appwrite-push-mqtt5
Open

feat(Push): add Appwrite Push (MQTT 5) adapter#129
deepshekhardas wants to merge 1 commit into
utopia-php:mainfrom
deepshekhardas:fix/122-appwrite-push-mqtt5

Conversation

@deepshekhardas

Copy link
Copy Markdown

Port of PR #122 by abnegate.

Adds Appwrite Push - a self-hosted, low-power alternative to FCM/APNS that publishes notifications over MQTT 5 to per-device topics.

Changes:

  • New MQTT 5 control-packet codec (Helpers/MQTT) - pure PHP, no extra dependency
  • New Appwrite Push adapter for MQTT 5 publishing
  • Fake broker for integration testing
  • Unit and integration tests

@greptile-apps

greptile-apps Bot commented Jun 14, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds a self-hosted Appwrite Push adapter that publishes notifications over MQTT 5 to per-device topics, including a pure-PHP MQTT 5 codec, a Swoole-based fake broker for integration testing, and unit/integration test suites.

  • src/Utopia/Messaging/Helpers/MQTT.php: Pure-PHP MQTT 5 codec handling CONNECT, CONNACK, PUBLISH, PUBACK, SUBSCRIBE, DISCONNECT, and PINGREQ/PINGRESP. readProperties has an early-return bug in its default case that silently drops all properties after an unrecognised identifier — several identifiers defined as constants in the class (PROPERTY_ASSIGNED_CLIENT_ID 0x12, PROPERTY_MAXIMUM_QOS 0x24, PROPERTY_RETAIN_AVAILABLE 0x25) have no corresponding case, meaning a real broker CONNACK can cause receiveMaximum to be silently missed.
  • src/Utopia/Messaging/Adapter/Push/Appwrite.php: Pipelined PUBLISH/PUBACK adapter that fans out notifications to up to receiveMaximum devices in flight at once; prior review threads note additional instance-state isolation concerns (readBuffer, receiveMaximum, rtrim).
  • tests/: Integration tests spin up the FakeBroker via proc_open, exercise the happy path, a 64-token pipeline burst, and stale-token rejection; MQTT unit tests cover codec round-trips and edge cases.

Confidence Score: 3/5

The MQTT codec property parser silently drops properties after any unhandled identifier, and several adapter-state isolation issues from prior threads remain unaddressed.

The readProperties early-return bug in MQTT.php means a real broker sending assignedClientIdentifier (0x12) before receiveMaximum (0x21) in CONNACK causes the adapter to silently use its default inflight window rather than the broker-advertised limit. Combined with the three instance-state concerns from prior threads, the adapter has multiple edge cases that could produce incorrect behaviour in production.

Files Needing Attention: src/Utopia/Messaging/Helpers/MQTT.php (readProperties switch completeness) and src/Utopia/Messaging/Adapter/Push/Appwrite.php (per-connection state isolation).

Important Files Changed

Filename Overview
src/Utopia/Messaging/Helpers/MQTT.php Pure-PHP MQTT 5 codec — the readProperties default case returns early on any unrecognised property ID, silently dropping all subsequent properties; ASSIGNED_CLIENT_ID 0x12, MAXIMUM_QOS 0x24, and RETAIN_AVAILABLE 0x25 lack switch cases, meaning a real broker CONNACK can cause receiveMaximum to never be parsed.
src/Utopia/Messaging/Adapter/Push/Appwrite.php New Appwrite Push adapter with pipelined PUBLISH/PUBACK loop; prior review threads surfaced readBuffer not being reset between connections, rtrim instead of trim on the endpoint, and receiveMaximum decreasing monotonically across adapter reuse.
tests/Messaging/Adapter/Push/FakeBroker.php Swoole-based fake MQTT 5 broker for integration tests; handles CONNECT, PUBLISH, DISCONNECT, and PINGREQ correctly; 15-second safety timeout ensures the process eventually exits if DISCONNECT is never received.
tests/Messaging/Adapter/Push/AppwriteTest.php Integration tests spin up FakeBroker via proc_open, exercise the happy path, pipeline burst (64 tokens), and the stale-token PUBACK rejection path; port allocation and process lifecycle are handled cleanly.
tests/Messaging/Helpers/MQTTTest.php Unit tests for the MQTT codec covering encode/decode round-trips, partial-buffer null returns, multi-packet concatenated buffers, and validation errors; good coverage.
Dockerfile Adds Swoole extension build step for integration testing; installs latest available Swoole from PECL without a pinned version, which could cause future CI drift.

Comments Outside Diff (1)

  1. src/Utopia/Messaging/Helpers/MQTT.php, line 1093-1094 (link)

    P1 Unknown property ID causes early return, silently dropping all subsequent properties

    When readProperties encounters an unrecognised identifier it immediately returns the properties parsed so far. Because MQTT 5 property values have variable widths, the offset cannot be safely advanced — so the only safe choice is to bail out. The consequence is that any property appearing after the unknown ID in the same packet is silently discarded.

    This bites for CONNACK specifically: PROPERTY_ASSIGNED_CLIENT_ID (0x12) is defined as a constant in this class but has no case in the switch. Mosquitto (and other standard brokers) sends assignedClientIdentifier whenever the client submitted an empty or auto-generated client ID. Because 0x12 < 0x21, it appears before PROPERTY_RECEIVE_MAXIMUM in a lexically ordered property list. When that happens the early return fires at 0x12, receiveMaximum is never read, and the adapter silently operates with whatever its current default is (256) instead of the broker's advertised limit.

    The same gap applies to PROPERTY_MAXIMUM_QOS (0x24) and PROPERTY_RETAIN_AVAILABLE (0x25), which are also absent from the switch despite being defined as constants and encoded by encodeConnack.

    The fix is two-part: add case branches for every known-but-unhandled property identifier (reading and advancing $offset for each), and replace return $properties with a throw new \RuntimeException(...) in the default case so that genuinely unknown IDs are surfaced as a protocol error rather than causing silent data loss.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/Utopia/Messaging/Helpers/MQTT.php
    Line: 1093-1094
    
    Comment:
    **Unknown property ID causes early return, silently dropping all subsequent properties**
    
    When `readProperties` encounters an unrecognised identifier it immediately `return`s the properties parsed so far. Because MQTT 5 property values have variable widths, the offset cannot be safely advanced — so the only safe choice is to bail out. The consequence is that any property appearing *after* the unknown ID in the same packet is silently discarded.
    
    This bites for CONNACK specifically: `PROPERTY_ASSIGNED_CLIENT_ID` (0x12) is defined as a constant in this class but has no `case` in the switch. Mosquitto (and other standard brokers) sends `assignedClientIdentifier` whenever the client submitted an empty or auto-generated client ID. Because 0x12 < 0x21, it appears *before* `PROPERTY_RECEIVE_MAXIMUM` in a lexically ordered property list. When that happens the early return fires at 0x12, `receiveMaximum` is never read, and the adapter silently operates with whatever its current default is (256) instead of the broker's advertised limit.
    
    The same gap applies to `PROPERTY_MAXIMUM_QOS` (0x24) and `PROPERTY_RETAIN_AVAILABLE` (0x25), which are also absent from the switch despite being defined as constants and encoded by `encodeConnack`.
    
    The fix is two-part: add `case` branches for every known-but-unhandled property identifier (reading and advancing `$offset` for each), and replace `return $properties` with a `throw new \RuntimeException(...)` in the `default` case so that genuinely unknown IDs are surfaced as a protocol error rather than causing silent data loss.
    
    ---
    
    For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

    Fix in Claude Code Fix in Codex

Fix All in Claude Code Fix All in Codex

Prompt To Fix All With AI
### Issue 1
src/Utopia/Messaging/Helpers/MQTT.php:1093-1094
**Unknown property ID causes early return, silently dropping all subsequent properties**

When `readProperties` encounters an unrecognised identifier it immediately `return`s the properties parsed so far. Because MQTT 5 property values have variable widths, the offset cannot be safely advanced — so the only safe choice is to bail out. The consequence is that any property appearing *after* the unknown ID in the same packet is silently discarded.

This bites for CONNACK specifically: `PROPERTY_ASSIGNED_CLIENT_ID` (0x12) is defined as a constant in this class but has no `case` in the switch. Mosquitto (and other standard brokers) sends `assignedClientIdentifier` whenever the client submitted an empty or auto-generated client ID. Because 0x12 < 0x21, it appears *before* `PROPERTY_RECEIVE_MAXIMUM` in a lexically ordered property list. When that happens the early return fires at 0x12, `receiveMaximum` is never read, and the adapter silently operates with whatever its current default is (256) instead of the broker's advertised limit.

The same gap applies to `PROPERTY_MAXIMUM_QOS` (0x24) and `PROPERTY_RETAIN_AVAILABLE` (0x25), which are also absent from the switch despite being defined as constants and encoded by `encodeConnack`.

The fix is two-part: add `case` branches for every known-but-unhandled property identifier (reading and advancing `$offset` for each), and replace `return $properties` with a `throw new \RuntimeException(...)` in the `default` case so that genuinely unknown IDs are surfaced as a protocol error rather than causing silent data loss.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (5): Last reviewed commit: "feat(Push): add Appwrite Push (MQTT 5) a..." | Re-trigger Greptile

}

public function getMaxMessagesPerRequest(): int
{

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 readBuffer not cleared between process() calls

$this->readBuffer is never reset at the start of each connection. If the adapter instance is reused (e.g., send() is called twice), or if the broker sends an extra packet after the last PUBACK (e.g., a PINGREQ that landed in the buffer just before disconnect), that residual data persists into the next call. On the next invocation readPacket() would immediately return the leftover packet as if it were the new connection's CONNACK, causing handshake() to throw "Broker did not respond with CONNACK" even on a healthy connection.

Add $this->readBuffer = ''; at the start of connect() or at the top of process() to isolate each connection's read state.


private function resolveEndpoint(): string
{
$endpoint = \rtrim($this->endpoint);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 rtrim strips only trailing whitespace, so a leading space in the configured endpoint (e.g., " broker.example.com") would produce a malformed URL like tls:// broker.example.com:8883 that stream_socket_client rejects. Use trim to strip both ends.

Suggested change
$endpoint = \rtrim($this->endpoint);
$endpoint = \trim($this->endpoint);

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +381 to +384
$packet = MQTT::decodePacket($this->readBuffer);
if ($packet !== null) {
return $packet;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 receiveMaximum decreases monotonically across process() calls

$this->receiveMaximum is instance state that is only ever updated via min() in handshake(). If the adapter is reused across multiple send() calls and the broker advertises a low receiveMaximum (say 10) on the first call, subsequent connections — even to a different broker endpoint — will be throttled to that minimum permanently for the lifetime of the object. Resetting it to the class-default (or to 65535) at the start of each connect() would make each connection's window independent.

@deepshekhardas

Copy link
Copy Markdown
Author

Following up - this PR has been open for 1 month. Let me know if any changes are needed or if the implementation approach needs adjustment.

Based on PR utopia-php#122 by abnegate. Adds Appwrite Push - a self-hosted MQTT 5 based push notification adapter with minimal MQTT 5 control-packet codec.
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