Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
bd85ff7
fix(web): wait for a pending Push connect and reconnect when the cred…
ArnabChatterjee20k Sep 29, 2026
9873f3f
fix(web): give each tab its own default Push client id
ArnabChatterjee20k Sep 29, 2026
edfb511
test(web): a credential change reconnects Push, also while it is conn…
ArnabChatterjee20k Sep 29, 2026
1ee6657
fix(android): serialize Push connects and reconnect when the credenti…
ArnabChatterjee20k Sep 29, 2026
79fcd09
test(android): a credential change reconnects Push, also while a subs…
ArnabChatterjee20k Sep 29, 2026
61bc789
fix(flutter): reconnect Push when the credential changes and keep oth…
ArnabChatterjee20k Sep 29, 2026
b97e909
fix(flutter): reconnect web Push when the credential changes and keep…
ArnabChatterjee20k Sep 29, 2026
7455396
test(flutter): a credential change reconnects Push, also while it is …
ArnabChatterjee20k Sep 29, 2026
fcc632d
fix(unity): share one Push connect, reconnect on credential change an…
ArnabChatterjee20k Sep 29, 2026
e9b8634
test(unity): a credential change reconnects Push, also while it is co…
ArnabChatterjee20k Sep 29, 2026
c1ba372
fix(apple): reconnect Push when the credential changes, also while it…
ArnabChatterjee20k Sep 29, 2026
b5d98e4
test(apple): a credential change reconnects Push, also while it is co…
ArnabChatterjee20k Sep 29, 2026
6e8b31d
fix(flutter): let a superseded Push connect stop waiting for its CONNACK
ArnabChatterjee20k Sep 29, 2026
b5468d9
test(mock): delay CONNACK for a slow credential and echo the client i…
ArnabChatterjee20k Sep 29, 2026
02b4811
fix(web): hold the tab's Push client id with a Web Lock so a duplicat…
ArnabChatterjee20k Sep 29, 2026
d9a98fe
docs(web): describe the per-tab default in setPushClientId
ArnabChatterjee20k Sep 29, 2026
97f7b32
fix(unity): read the Push client id on every connect and ignore a rep…
ArnabChatterjee20k Sep 29, 2026
c52af3e
test(web): check concurrent refusals and per-tab client ids, and swit…
ArnabChatterjee20k Sep 29, 2026
bd41ba2
test: switch the credential while a slow connect is pending, and tigh…
ArnabChatterjee20k Sep 29, 2026
7588b12
fix(web): claim the Push tab id once per page and hold it for the pag…
ArnabChatterjee20k Sep 29, 2026
42a6226
test(web): compare client ids within a page and with a popup that sha…
ArnabChatterjee20k Sep 29, 2026
2311a42
test(android): switch with its own user and show what a failed backgr…
ArnabChatterjee20k Sep 29, 2026
02ed5de
test(mock): publish the e2e-whoami reply at QoS 0 with a literal the …
ArnabChatterjee20k Sep 29, 2026
e53ea71
fix(web): give each concurrent Push connection a distinct client id
ArnabChatterjee20k Sep 29, 2026
0b4b0a2
test(web): assert concurrent Push instances get distinct client ids
ArnabChatterjee20k Sep 29, 2026
9eea9f1
fix(web): claim the Push client id once per instance to avoid strandi…
ArnabChatterjee20k Sep 29, 2026
959aed3
fix(web): make the Push client-id claim safe across close and reopen
ArnabChatterjee20k Sep 29, 2026
459d84c
style(web): wrap the long client-id claim condition for prettier
ArnabChatterjee20k Sep 29, 2026
09ed067
test: log recent SUBSCRIBEs when an Android background restore sees a…
ArnabChatterjee20k Sep 29, 2026
4347fee
Revert "test: log recent SUBSCRIBEs when an Android background restor…
ArnabChatterjee20k Sep 29, 2026
2731c99
test(android): let the mock's delayed publishes drain before the back…
ArnabChatterjee20k Sep 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions mock-server/app/mqtt.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
* - CONNECT is accepted unless the credential is "deny" or "deny:<reason>", which is refused
* (with <reason> as the Reason String) so a test can assert a rejected connection and that
* its reason reaches the SDK; the projectId user property becomes the connection prefix.
* A "slow:<anything>" credential is accepted after 500 ms, so a test can act while it connects.
* - SUBSCRIBE to "e2e-whoami" publishes the connection's client id on "e2e-whoami".
* - SUBSCRIBE to "e2e-disconnect/<reason>" makes the broker DISCONNECT the client with <reason>
* as the Reason String.
* - "e2e-replay" keeps a replay position per project and client id, like the real broker does
Expand Down Expand Up @@ -79,6 +81,14 @@ public function onConnect(Connect $connect, Connection $connection): Connack|Aut
return Connack::refuse(Connack::NOT_AUTHORIZED, $properties);
}

if (\str_starts_with($credential, 'slow:')) {
if (\Swoole\Coroutine::getCid() > 0) {
\Swoole\Coroutine::sleep(0.5);
} else {
\usleep(500_000);
}
}

// Mirror the real broker: derive a stable per-connection id when the client sends
// none, so an empty client id (the SDKs' default in reliable mode) is accepted.
$clientId = $connect->clientId;
Expand Down Expand Up @@ -144,6 +154,9 @@ public function onSubscribe(Subscribe $subscribe, Connection $connection): Subac
}
});
}
if ($filter->topic === 'e2e-whoami') {
\Swoole\Timer::after(100, fn () => $connection->publish('e2e-whoami', $connection->getClientId(), qos: 0));
}
if ($filter->topic === 'e2e-replay-publish') {
// Only the publisher's project's clients, like a real publish.
foreach (\array_keys($this->replayQueues) as $key) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,12 @@ class Push(
@Volatile
private var mqtt: Mqtt5AsyncClient? = null

private var connectedConfig: PushConfig? = null
private val connectLock = Any()

@Volatile
private var connectionEpoch = 0

// Local id -> subscription (filter + callback + per-sub options). The id is never sent to
// the broker; it only lets the same topic carry more than one callback.
private val subscriptions = ConcurrentHashMap<String, Sub>()
Expand Down Expand Up @@ -159,7 +165,10 @@ class Push(
var background: Boolean,
var title: String?,
var retry: Boolean,
)
) {
@Volatile
var client: Mqtt5AsyncClient? = null
}

// The broker subscription for a filter uses the highest QoS any subscription on it wants,
// so a QoS-0 subscription never downgrades a QoS-1 one sharing the filter.
Expand Down Expand Up @@ -427,15 +436,28 @@ class Push(

// In-process host: connect and register this call's new filters, blocking on each SUBACK.
private fun subscribeInProcess(ids: List<String>) {
val client = connect()
for (id in ids) {
val sub = subscriptions[id] ?: continue
client.subscribeWith()
.topicFilter(sub.filter)
.qos(effectiveQos(sub.filter))
.callback { publish -> sub.callback(publish.toPushMessage()) }
.send()
.get()
while (true) {
val (client, epoch) = synchronized(connectLock) { connect() to connectionEpoch }
try {
for (id in ids) {
val sub = subscriptions[id] ?: continue
if (sub.client === client) {
continue
}
client.subscribeWith()
.topicFilter(sub.filter)
.qos(effectiveQos(sub.filter))
.callback { publish -> sub.callback(publish.toPushMessage()) }
.send()
.get()
sub.client = client
}
return
} catch (e: Exception) {
if (epoch == connectionEpoch) {
throw e
}
}
}
}

Expand All @@ -446,6 +468,7 @@ class Push(
mqtt?.disconnect()
mqtt = null
}
subscriptions.values.forEach { it.client = null }
PushBackground.errorHandler = { deliverBackgroundError(it) }
PushBackground.host(appContext, buildConfig(), currentListeners())
backgroundActive = true
Expand All @@ -456,12 +479,16 @@ class Push(
private fun ensureInProcessSubscribed() {
val client = connect()
for (sub in subscriptions.values) {
if (sub.client === client) {
continue
}
client.subscribeWith()
.topicFilter(sub.filter)
.qos(effectiveQos(sub.filter))
.callback { publish -> sub.callback(publish.toPushMessage()) }
.send()
.reportFailure(client)
sub.client = client
}
}

Expand All @@ -478,6 +505,7 @@ class Push(
.callback { publish -> sub.callback(publish.toPushMessage()) }
.send()
.reportFailure(client)
sub.client = client
}
}

Expand Down Expand Up @@ -593,15 +621,20 @@ class Push(
}
}

private fun connect(): Mqtt5AsyncClient {
private fun connect(): Mqtt5AsyncClient = synchronized(connectLock) {
val config = buildConfig()
// Reuse the existing client even while it is mid automatic-reconnect. Building a second
// client here would give two clients the same stable id, so they would fight over the
// broker session and callbacks could stay attached to the abandoned one.
mqtt?.let { existing ->
return existing
if (config == connectedConfig) {
return existing
}
connectionEpoch++
existing.disconnect()
mqtt = null
}

val config = buildConfig()
// The listeners surface the lifecycle to onOpen/onClose and every connection error to
// onError, except a refused initial CONNECT: pushConnect throws that one, and the
// failing call reports it.
Expand All @@ -612,15 +645,33 @@ class Push(
onError = { reportAsync(it) },
)
mqtt = client
connectedConfig = config

try {
client.pushConnect(config)
} catch (e: Exception) {
close()
client.disconnect()
if (mqtt === client) {
mqtt = null
connectedConfig = null
}
throw e
}

return client
for (sub in subscriptions.values) {
if (sub.client == null || sub.client === client) {
continue
}
client.subscribeWith()
.topicFilter(sub.filter)
.qos(effectiveQos(sub.filter))
.callback { publish -> sub.callback(publish.toPushMessage()) }
.send()
.reportFailure(client)
sub.client = client
}

client
}

private fun buildConfig(): PushConfig {
Expand Down
92 changes: 88 additions & 4 deletions templates/apple/Sources/Services/Push.swift.twig
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ open class Push: Service {

private var mqtt: MQTTClient?
private var connecting: Task<MQTTClient, Swift.Error>?
private var connectedKey = ""
private var connectionEpoch = 0
private var resubscribeOnConnect = false
private var retired = Set<ObjectIdentifier>()

private var onOpenCb: (() -> Void)?
private var onCloseCb: (() -> Void)?
Expand Down Expand Up @@ -248,7 +252,13 @@ open class Push: Service {
requestNotificationAuthorization()
}

let mqtt = try await connect()
let mqtt: MQTTClient
do {
mqtt = try await connect()
} catch is SupersededError {
return try await subscribe(topics.map { $0 as TopicValue }, background: background, title: title, retry: retry, callback: callback)
}
let epoch = sync.sync { connectionEpoch }

var ids: [String] = []
for topic in topics {
Expand All @@ -263,6 +273,16 @@ open class Push: Service {
}
try await brokerSubscribe(mqtt, infos)
} catch {
let superseded = sync.sync { () -> Bool in
guard connectionEpoch != epoch else { return false }
for id in ids {
subscriptions.removeValue(forKey: id)
}
return true
}
if superseded {
return try await subscribe(topics.map { $0 as TopicValue }, background: background, title: title, retry: retry, callback: callback)
}
// The SUBSCRIBE failed; roll this call's registrations back (and drop any broker
// filter no other local subscriber owns) so a failed subscribe leaks no callback.
let filters = sync.sync { () -> [String] in
Expand Down Expand Up @@ -405,6 +425,19 @@ open class Push: Service {
}

private func connect() async throws -> MQTTClient {
let key = credentialKey()
let superseded = sync.sync { () -> MQTTClient? in
guard self.mqtt != nil || self.connecting != nil, self.connectedKey != key else { return nil }
self.connectionEpoch += 1
self.connecting = nil
let existing = self.mqtt
self.mqtt = nil
self.resubscribeOnConnect = !self.subscriptions.isEmpty
return existing
}
if let superseded {
retire(superseded)
}
if let existing = sync.sync(execute: { self.mqtt }) {
return existing
}
Expand All @@ -414,16 +447,32 @@ open class Push: Service {
if let inflight = self.connecting {
return inflight
}
self.connectedKey = key
let epoch = self.connectionEpoch
let created = Task { () throws -> MQTTClient in
do {
let client = try await self.open()
self.sync.sync {
let current = self.sync.sync { () -> Bool in
guard self.connectionEpoch == epoch else { return false }
self.mqtt = client
self.connecting = nil
return true
}
guard current else {
self.retire(client)
throw SupersededError()
}
self.resubscribeIfSuperseded(client)
return client
} catch {
self.sync.sync { self.connecting = nil }
let current = self.sync.sync { () -> Bool in
guard self.connectionEpoch == epoch else { return false }
self.connecting = nil
return true
}
if !current {
throw SupersededError()
}
throw error
}
}
Expand All @@ -433,6 +482,36 @@ open class Push: Service {
return try await task.value
}

private func credentialKey() -> String {
let jwt = client.config["jwt"] ?? ""
let session = client.config["session"] ?? ""
let credential = jwt.isEmpty ? "appwrite-session|\(session)" : "appwrite-jwt|\(jwt)"
return [host, String(port), client.config["project"] ?? "", resolvedClientId, credential].joined(separator: "|")
}

private func retire(_ client: MQTTClient) {
sync.sync { _ = retired.insert(ObjectIdentifier(client)) }
DispatchQueue.global().async {
try? client.disconnect().wait()
try? client.syncShutdownGracefully()
}
}

private func resubscribeIfSuperseded(_ client: MQTTClient) {
let infos = sync.sync { () -> [MQTTSubscribeInfoV5] in
guard resubscribeOnConnect else { return [] }
resubscribeOnConnect = false
return Set(subscriptions.values.map { $0.filter }).map {
MQTTSubscribeInfoV5(topicFilter: $0, qos: effectiveQos($0))
}
}
if !infos.isEmpty {
inBackground { [weak self] in
try await self?.brokerSubscribe(client, infos)
}
}
}

// Read the credential off the client and build the CONNECT properties (enhanced auth plus
// the project id). Rebuilt on every connect so a reconnect picks up a refreshed credential.
private func connectProperties() throws -> MQTTProperties {
Expand Down Expand Up @@ -522,7 +601,10 @@ open class Push: Service {

// Surface the connection lifecycle to the caller's onClose hook, and recover from an
// unexpected drop (a deliberate close() is skipped via the `closing` flag).
mqtt.addCloseListener(named: "{{ spec.info.title | caseLower }}-push") { [weak self] result in
mqtt.addCloseListener(named: "{{ spec.info.title | caseLower }}-push") { [weak self, weak mqtt] result in
if let self, let mqtt, self.sync.sync(execute: { self.retired.remove(ObjectIdentifier(mqtt)) != nil }) {
return
}
if case .failure(let error) = result {
self?.surface(Push.describe(error))
} else if let self, !self.sync.sync(execute: { self.closing }) {
Expand Down Expand Up @@ -783,3 +865,5 @@ private func matches(filter: String, topic: String) -> Bool {
}
return filterParts.count == topicParts.count
}

private struct SupersededError: Swift.Error {}
Loading
Loading