diff --git a/mock-server/app/mqtt.php b/mock-server/app/mqtt.php
index 29d8191fde..e4ffa532d2 100644
--- a/mock-server/app/mqtt.php
+++ b/mock-server/app/mqtt.php
@@ -9,6 +9,8 @@
* - CONNECT is accepted unless the credential is "deny" or "deny:", which is refused
* (with 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:" 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/" makes the broker DISCONNECT the client with
* as the Reason String.
* - "e2e-replay" keeps a replay position per project and client id, like the real broker does
@@ -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;
@@ -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) {
diff --git a/templates/android/library/src/main/java/io/package/services/Push.kt.twig b/templates/android/library/src/main/java/io/package/services/Push.kt.twig
index 9dc5e79c92..fe0a715fa5 100644
--- a/templates/android/library/src/main/java/io/package/services/Push.kt.twig
+++ b/templates/android/library/src/main/java/io/package/services/Push.kt.twig
@@ -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()
@@ -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.
@@ -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) {
- 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
+ }
+ }
}
}
@@ -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
@@ -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
}
}
@@ -478,6 +505,7 @@ class Push(
.callback { publish -> sub.callback(publish.toPushMessage()) }
.send()
.reportFailure(client)
+ sub.client = client
}
}
@@ -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.
@@ -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 {
diff --git a/templates/apple/Sources/Services/Push.swift.twig b/templates/apple/Sources/Services/Push.swift.twig
index a110bb1d50..d177d5ad9e 100644
--- a/templates/apple/Sources/Services/Push.swift.twig
+++ b/templates/apple/Sources/Services/Push.swift.twig
@@ -100,6 +100,10 @@ open class Push: Service {
private var mqtt: MQTTClient?
private var connecting: Task?
+ private var connectedKey = ""
+ private var connectionEpoch = 0
+ private var resubscribeOnConnect = false
+ private var retired = Set()
private var onOpenCb: (() -> Void)?
private var onCloseCb: (() -> Void)?
@@ -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 {
@@ -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
@@ -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
}
@@ -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
}
}
@@ -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 {
@@ -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 }) {
@@ -783,3 +865,5 @@ private func matches(filter: String, topic: String) -> Bool {
}
return filterParts.count == topicParts.count
}
+
+private struct SupersededError: Swift.Error {}
diff --git a/templates/flutter/lib/src/mqtt_browser.dart.twig b/templates/flutter/lib/src/mqtt_browser.dart.twig
index 42eddce3fe..2cd292d6f2 100644
--- a/templates/flutter/lib/src/mqtt_browser.dart.twig
+++ b/templates/flutter/lib/src/mqtt_browser.dart.twig
@@ -62,6 +62,10 @@ class PushWeb implements Push {
MqttBrowserClient? _mqtt;
Future? _connecting;
+ int _connectionEpoch = 0;
+ Completer? _openCancel;
+ String _connectedKey = '';
+ bool _resubscribeOnConnect = false;
void Function()? _onOpen;
void Function()? _onClose;
@@ -253,9 +257,19 @@ class PushWeb implements Push {
topicList = _topicList(client, topics);
mqtt = await _connect();
} catch (e) {
+ if (e is _Superseded) {
+ return subscribe(
+ topics,
+ callback,
+ background: background,
+ title: title,
+ retry: retry,
+ );
+ }
_notify(e);
rethrow;
}
+ final epoch = _connectionEpoch;
// The own-user topic (topics == null) defaults to background delivery, like FCM/APNs.
final wantsBackground = background ?? topics == null;
@@ -336,6 +350,18 @@ class PushWeb implements Push {
}
await Future.wait(acks);
} catch (e) {
+ if (epoch != _connectionEpoch) {
+ for (final id in ids) {
+ _subscriptions.remove(id);
+ }
+ return subscribe(
+ topics,
+ callback,
+ background: background,
+ title: title,
+ retry: retry,
+ );
+ }
unsubscribe();
_notify(e);
rethrow;
@@ -406,6 +432,24 @@ class PushWeb implements Push {
}
Future _connect() {
+ if ((_mqtt != null || _connecting != null) &&
+ _connectedKey != _credentialKey) {
+ _connectionEpoch++;
+ _connecting = null;
+ _openCancel?.complete();
+ _openCancel = null;
+ for (final waiting in _subAcks.values) {
+ for (final completer in waiting) {
+ if (!completer.isCompleted) {
+ completer.completeError(const _Superseded());
+ }
+ }
+ }
+ _subAcks.clear();
+ _mqtt?.disconnect();
+ _mqtt = null;
+ _resubscribeOnConnect = _subscriptions.isNotEmpty;
+ }
final mqtt = _mqtt;
if (mqtt != null &&
mqtt.connectionStatus?.state == MqttConnectionState.connected) {
@@ -417,32 +461,54 @@ class PushWeb implements Push {
_connecting = connecting;
// A failed open must not poison future attempts — clear the cached in-flight
// future so a later subscribe/publish can retry.
+ final pending = connecting;
unawaited(
- connecting.catchError((Object _) {
- _connecting = null;
+ pending.catchError((Object _) {
+ if (identical(_connecting, pending)) {
+ _connecting = null;
+ }
}),
);
}
return connecting.then((_) => _mqtt!);
}
- Future _open() async {
- // Read the credential set on the client (via Client.setJWT / setSession).
+ // The credential set on the client (via Client.setJWT / setSession), as (method, credential).
+ (String, String) _credential() {
final jwt = client.config['jwt'] ?? client.config['jWT'];
final session = client.config['session'];
- final String authMethod;
- final String credential;
if (jwt != null && jwt.isNotEmpty) {
- authMethod = 'appwrite-jwt';
- credential = jwt;
- } else if (session != null && session.isNotEmpty) {
- authMethod = 'appwrite-session';
- credential = session;
- } else {
- throw {{spec.info.title | caseUcfirst}}Exception(
- 'No credential set on the client; call Client.setJWT() or Client.setSession() first.',
- );
+ return ('appwrite-jwt', jwt);
}
+ if (session != null && session.isNotEmpty) {
+ return ('appwrite-session', session);
+ }
+ throw {{spec.info.title | caseUcfirst}}Exception(
+ 'No credential set on the client; call Client.setJWT() or Client.setSession() first.',
+ );
+ }
+
+ String get _credentialKey {
+ try {
+ final (authMethod, credential) = _credential();
+ return [
+ _host,
+ _port,
+ _path,
+ client.config['project'] ?? '',
+ _resolvedClientId,
+ authMethod,
+ credential,
+ ].join('|');
+ } catch (_) {
+ return '';
+ }
+ }
+
+ Future _open() async {
+ final (authMethod, credential) = _credential();
+ _connectedKey = _credentialKey;
+ final epoch = _connectionEpoch;
final project = client.config['project'] ?? '';
// An empty client id makes the broker derive a stable id server-side (keyed on the
@@ -497,15 +563,23 @@ class PushWeb implements Push {
_mqtt = mqtt;
- final failure = await _connectOrFail(mqtt, (error) {
+ final cancel = Completer();
+ _openCancel = cancel;
+ final failure = await _connectOrFail(mqtt, cancel.future, (error) {
+ if (epoch != _connectionEpoch) {
+ return;
+ }
// Let go of the client first: it is still being wound down, and a close() from
// onError must not touch it.
if (identical(_mqtt, mqtt)) {
_mqtt = null;
}
- close();
_notify(error);
});
+ if (epoch != _connectionEpoch) {
+ mqtt.disconnect();
+ throw const _Superseded();
+ }
if (failure != null) {
throw failure;
}
@@ -513,6 +587,19 @@ class PushWeb implements Push {
// Attach the delivery listener only after CONNACK — the `updates` stream is not
// available until the connection is established.
mqtt.updates.listen(_onData);
+
+ if (_resubscribeOnConnect) {
+ _resubscribeOnConnect = false;
+ final filters = _subscriptions.values.map((s) => s.filter).toSet();
+ for (final filter in filters) {
+ mqtt.subscribeWithSubscriptionList([
+ MqttSubscription.withMaximumQos(
+ MqttSubscriptionTopic(filter),
+ _effectiveQos(filter),
+ ),
+ ]);
+ }
+ }
}
void _onData(List> events) {
@@ -587,6 +674,7 @@ class PushWeb implements Push {
/// watched for the refusal as well, and the pending connect() is then let go.
Future
+
+
diff --git a/tests/e2e/languages/web/tab.html b/tests/e2e/languages/web/tab.html
new file mode 100644
index 0000000000..ed90258009
--- /dev/null
+++ b/tests/e2e/languages/web/tab.html
@@ -0,0 +1,22 @@
+
+
+
+