git » sdk » commit 76adb17

Track MUC sync/join failure

author Stephen Paul Weber
2026-04-21 19:10:00 UTC
committer Stephen Paul Weber
2026-04-21 19:10:00 UTC
parent 2875dd7358103628b7187dce5fa98ad600df12d9

Track MUC sync/join failure

Don't just leave it "syncing" forever when it's not doing anything. Mark
it as failed properly.

borogove/Chat.hx +8 -3
borogove/Client.hx +11 -0
test/TestChat.hx +75 -0

diff --git a/borogove/Chat.hx b/borogove/Chat.hx
index 9eee475..8c4db7a 100644
--- a/borogove/Chat.hx
+++ b/borogove/Chat.hx
@@ -1299,6 +1299,8 @@ class Channel extends Chat {
 	private var disco: Caps = new Caps("", [], ["http://jabber.org/protocol/muc"], []);
 	@:allow(borogove)
 	private var inSync = false;
+	@:allow(borogove)
+	private var joinFailed = null;
 	private var sync = null;
 	private var forceLive = false;
 	private var _nickInUse = null;
@@ -1601,6 +1603,8 @@ class Channel extends Chat {
 				doSync(null, syncPoint.sortId);
 			} else {
 				trace("SYNC failed", chatId, stanza);
+				joinFailed = stanza;
+				client.trigger("chats/update", [this]);
 			}
 		});
 		sync.fetchNext();
@@ -1672,7 +1676,7 @@ trace("XYZZY no MUC avatar locally matching so fetch vcard", chatId, avatarSha1H
 	}
 
 	override public function syncing() {
-		return !inSync || !livePresence();
+		return sync != null || (!livePresence() && joinFailed == null);
 	}
 
 	override private function setLastMessage(message:Null<ChatMessage>) {
@@ -1692,6 +1696,7 @@ trace("XYZZY no MUC avatar locally matching so fetch vcard", chatId, avatarSha1H
 		return _nickInUse ?? client.displayName();
 	}
 
+	@:allow(borogove)
 	private function getFullJid() {
 		return JID.parse(chatId).withResource(nickInUse());
 	}
@@ -1754,7 +1759,7 @@ trace("XYZZY no MUC avatar locally matching so fetch vcard", chatId, avatarSha1H
 	@HaxeCBridge.noemit // on superclass as abstract
 	public function getMessagesAfter(after: Null<ChatMessage>):Promise<Array<ChatMessage>> {
 		if (after != null && after.chatId() != chatId) throw "Cannot look after from a different chat";
-		if (after != null && lastMessage != null && lastMessage.canReplace(after) && !syncing()) {
+		if (after != null && lastMessage != null && lastMessage.canReplace(after) && inSync) {
 			return Promise.resolve([]);
 		}
 
@@ -1785,7 +1790,7 @@ trace("XYZZY no MUC avatar locally matching so fetch vcard", chatId, avatarSha1H
 
 	@:allow(borogove)
 	private function prepareIncomingMessage(message:ChatMessageBuilder, stanza:Stanza) {
-		message.syncPoint = !syncing();
+		message.syncPoint = inSync;
 		if (message.type == MessageChat) message.type = MessageChannelPrivate;
 		if (message.sortId == null) {
 			if (sortId != null && message.type == MessageChannel) {
diff --git a/borogove/Client.hx b/borogove/Client.hx
index bcc8244..383e809 100644
--- a/borogove/Client.hx
+++ b/borogove/Client.hx
@@ -442,6 +442,17 @@ class Client extends EventEmitter {
 				}
 			}
 
+			if (stanza.attr.get("from") != null && stanza.attr.get("type") == "error") {
+				final from = JID.parse(stanza.attr.get("from"));
+				final chat = getChat(from.asBare().asString());
+				final channel = Std.downcast(chat, Channel);
+				if (channel != null && from.asString() == channel.getFullJid().asString()) {
+					trace("Join failed", channel.chatId, stanza);
+					channel.joinFailed = stanza;
+					this.trigger("chats/update", [channel]);
+				}
+			}
+
 			return EventUnhandled;
 		});
 	}
diff --git a/test/TestChat.hx b/test/TestChat.hx
index 4c6323c..6ae661d 100644
--- a/test/TestChat.hx
+++ b/test/TestChat.hx
@@ -7,6 +7,9 @@ import borogove.ChatMessageBuilder;
 import borogove.Stanza;
 import borogove.JID;
 import borogove.persistence.Dummy;
+import borogove.Chat.Channel;
+import borogove.Chat.AvailableChat;
+import borogove.Caps.Identity;
 
 @:access(borogove)
 class TestChat extends utest.Test {
@@ -276,4 +279,76 @@ class TestChat extends utest.Test {
 		chat.presence.set("mynick", p2);
 		Assert.isTrue(chat.canModerate());
 	}
+
+	public function testJoinFailure() {
+		final persistence = new Dummy();
+		final client = new Client("test@example.com", persistence);
+		final caps = new borogove.Caps("", [new Identity("conference", "text", "Channel")], ["http://jabber.org/protocol/muc"], []);
+		final availableChat = new AvailableChat("channel@example.com", "Channel", "", caps);
+		final channel = cast(client.startChat(availableChat), Channel);
+
+		Assert.isTrue(channel.syncing(), "Should be syncing initially");
+
+		final errorStanza = new Stanza("presence", { xmlns: "jabber:client", from: "channel@example.com/test", to: "test@example.com", type: "error" })
+			.tag("error", { type: "auth" })
+				.tag("forbidden", { xmlns: "urn:ietf:params:xml:ns:xmpp-stanzas" }).up()
+			.up();
+
+		client.stream.onStanza(errorStanza);
+
+		Assert.equals(channel.joinFailed, errorStanza, "joinFailed should be set");
+		Assert.isFalse(channel.syncing(), "Should NOT be syncing after join failure");
+	}
+
+	public function testSyncFailure(async: Async) {
+		final persistence = new Dummy();
+		final client = new Client("test@example.com", persistence);
+		final caps = new borogove.Caps("", [new Identity("conference", "text", "Channel")], ["http://jabber.org/protocol/muc", "urn:xmpp:mam:2"], []);
+		final availableChat = new AvailableChat("channel@example.com", "Channel", "", caps);
+		final channel = cast(client.startChat(availableChat), Channel);
+
+		Assert.isTrue(channel.syncing(), "Should be syncing initially");
+
+		client.stream.on("sendStanza", (stanza: Stanza) -> {
+			if (stanza.name == "iq") {
+				// Delay of 0 to force async like in real life
+				haxe.Timer.delay(() -> {
+					final errorStanza = new Stanza("iq", { xmlns: "jabber:client", type: "error", id: stanza.attr.get("id") })
+						.tag("error", { type: "cancel" })
+							.tag("feature-not-implemented", { xmlns: "urn:ietf:params:xml:ns:xmpp-stanzas" }).up()
+						.up();
+
+					client.stream.onStanza(errorStanza);
+
+					Assert.isNull(channel.sync, "sync should be cleared after failure");
+					Assert.isFalse(channel.inSync, "Should not be inSync");
+					Assert.isFalse(channel.syncing(), "Should NOT be syncing after sync failure");
+					async.done();
+				}, 0);
+			}
+
+			return EventHandled;
+		});
+
+		channel.doSync(null);
+		Assert.notNull(channel.sync, "sync should be set during sync");
+		Assert.isTrue(channel.syncing(), "Should be syncing during sync");
+	}
+
+	public function testSyncPointWhenNotInSync() {
+		final persistence = new Dummy();
+		final client = new Client("test@example.com", persistence);
+		final channel = new Channel(client, client.stream, persistence, "channel@example.com");
+
+		channel.inSync = false;
+		final builder = new ChatMessageBuilder();
+		final stanza = new Stanza("message", { from: "channel@example.com/someone" });
+		channel.prepareIncomingMessage(builder, stanza);
+
+		Assert.isFalse(builder.syncPoint, "Message should NOT have syncPoint if NOT inSync");
+
+		channel.inSync = true;
+		channel.prepareIncomingMessage(builder, stanza);
+		Assert.isTrue(builder.syncPoint, "Message SHOULD have syncPoint if inSync");
+	}
 }