git » sdk » commit b7e106e

Periodic MUC self ping

author Eric Roberts
2026-09-09 17:24:23 UTC
committer Stephen Paul Weber
2026-09-09 17:44:41 UTC
parent 1f25ae98150f326c23a53367f2ec932359bea480

Periodic MUC self ping

The basics of how it works is that ChannelPinger keeps a map of known
open channels and when they should be pinged next. When we successfully
join a channel we add it to the schedule to be pinged in 5 minutes and
then everytime activity comes in for that channel that indicates we
still have a connection we push the timer out 5 minutes again.

Every one minute we check if there are any channels that are past due
for a ping and send those ones.

When we successfully self ping a channel we schedule the next ping at
that time.

When we leave a channel we remove the channel from the scheduler so we
stop pinging it.

When we're going to send a Stanza we look if any channels should be
pinged in the next 30 seconds, and if so send all that would be pinged
in the next 60 seconds (previously decided rules, but easy to change if
we change our mind).

Having startTimer twice because of resuming/fresh session and stopTimer
twice because of stream offline/user logout seems like a smell to me
that there isn't a shared initialization, but I'm not sure if there's
enough to do something about it now. Open to suggestions/ideas there.

I added filter to borogove map because this does exist on haxe's map,
but I could only run haxe testjs.hxml after adding to borogove map
(haxe test.hxml ran fine without).

Similarly I had to add a return type annotation to OMEMO.hx because
something caused it to start inferring the wrong thing.

borogove/ChannelPinger.hx +70 -0
borogove/Chat.hx +3 -4
borogove/Client.hx +12 -0
borogove/Map.js.hx +4 -0
borogove/OMEMO.hx +1 -1
test/TestAll.hx +1 -0
test/TestChannelPinger.hx +158 -0

diff --git a/borogove/ChannelPinger.hx b/borogove/ChannelPinger.hx
new file mode 100644
index 0000000..872f350
--- /dev/null
+++ b/borogove/ChannelPinger.hx
@@ -0,0 +1,70 @@
+package borogove;
+
+import borogove.Chat;
+
+using Lambda;
+
+private typedef ScheduledChannel = {
+	final channel: Channel;
+	var deadline: Float;
+}
+
+class ChannelPinger {
+	private static inline final PING_INTERVAL_MS = 5 * 60 * 1000;
+	private static inline final CHECK_INTERVAL_MS = 60 * 1000;
+	private static inline final PING_TRIGGER_WINDOW_MS = 30 * 1000;
+	private static inline final COALESCE_WINDOW_MS = PING_TRIGGER_WINDOW_MS * 2;
+
+	private final now: ()->Float;
+	private final scheduledChannels = new Map<String, ScheduledChannel>();
+	private var timer: Null<haxe.Timer> = null;
+
+	public function new(?now: ()->Float) {
+		this.now = now ?? (() -> haxe.Timer.stamp() * 1000);
+	}
+
+	public function startTimer(): Void {
+		if (timer != null) throw "ChannelPinger timer already exists";
+		timer = new haxe.Timer(CHECK_INTERVAL_MS);
+		timer.run = pingPastDue;
+	}
+
+	public function stopTimer(): Void {
+		timer?.stop();
+		timer = null;
+	}
+
+	public function schedule(channel: Channel, ?deadline: Float): Void {
+		if (channel.uiState != Open) return;
+
+		scheduledChannels.set(channel.chatId, {
+			channel: channel,
+			deadline: deadline ?? now() + PING_INTERVAL_MS,
+		});
+	}
+
+	public function remove(channel: Channel): Void {
+		scheduledChannels.remove(channel.chatId);
+	}
+
+	public function pingPastDue(): Void {
+		pingDueBy(now());
+	}
+
+	public function pingDueInWindow(): Void {
+		final currentTime = now();
+		if (getDueBy(currentTime + PING_TRIGGER_WINDOW_MS).length > 0) {
+			pingDueBy(currentTime + COALESCE_WINDOW_MS);
+		}
+	}
+
+	private function pingDueBy(cutoff: Float): Void {
+		final due = getDueBy(cutoff);
+
+		for (scheduled in due) scheduled.channel.selfPing(false);
+	}
+
+	private function getDueBy(cutoff: Float): Array<ScheduledChannel> {
+		return scheduledChannels.filter(sc -> sc.deadline <= cutoff);
+	}
+}
diff --git a/borogove/Chat.hx b/borogove/Chat.hx
index 4d07487..149ec92 100644
--- a/borogove/Chat.hx
+++ b/borogove/Chat.hx
@@ -1511,6 +1511,7 @@ class Channel extends Chat {
 		if (uiState == Invited) return;
 
 		if (uiState == Closed) {
+			client.channelPinger.remove(this);
 			client.sendPresence(
 				getFullJid().asString(),
 				(stanza) -> {
@@ -1585,10 +1586,7 @@ class Channel extends Chat {
 			final desiredFullJid = JID.parse(chatId).withResource(client.displayName());
 			client.sendPresence(desiredFullJid.asString());
 		}
-		// We did a self ping to see if we were in the room and found we are
-		// But we may have missed messages if we were disconnected in the middle
-		inSync = false;
-		persistence.syncPoint(client.accountId(), chatId).then(point -> doSync(point));
+		client.channelPinger.schedule(this);
 	}
 
 	override public function getDisplayName() {
@@ -1817,6 +1815,7 @@ class Channel extends Chat {
 			} else {
 				self = member;
 				outbox.start();
+				client.channelPinger.schedule(this);
 			}
 			if (!noStore) client.trigger("chats/update", [this]);
 		}
diff --git a/borogove/Client.hx b/borogove/Client.hx
index 4eb44d3..d58868b 100644
--- a/borogove/Client.hx
+++ b/borogove/Client.hx
@@ -129,6 +129,8 @@ class Client extends EventEmitter {
 	@:allow(borogove)
 	private var inSync(default, null) = false;
 	private var firstSync = true;
+	@:allow(borogove.Channel)
+	private final channelPinger: ChannelPinger;
 
 	/**
 		Create a new Client to connect to a particular account
@@ -155,8 +157,10 @@ class Client extends EventEmitter {
 		if (SignalProtocol.exists()) this.omemo = new OMEMO(this, persistence);
 #end
 		stream = new Stream();
+		channelPinger = new ChannelPinger();
 		stream.on("status/online", this.onConnected);
 		stream.on("status/offline", (data) -> {
+			channelPinger.stopTimer();
 			this.trigger("status/offline", {});
 		});
 
@@ -479,6 +483,10 @@ class Client extends EventEmitter {
 			final channel = Std.downcast(chat, Channel);
 			if (channel != null) channel.selfPing(true);
 		}
+		if (stanza.attr.get("type") != "error" && from != null) {
+			final channel = Std.downcast(getChat(from.asBare().asString()), Channel);
+			if (channel != null) channelPinger.schedule(channel);
+		}
 
 		var newChat: Null<Chat> = null;
 
@@ -900,6 +908,7 @@ class Client extends EventEmitter {
 		@param completely if true chats, messages, etc will be deleted as well
 	**/
 	public function logout(completely: Bool) {
+		channelPinger.stopTimer();
 		persistence.removeAccount(accountId(), completely);
 		final disable = new Push2Disable(jid.asBare().asString());
 		disable.onFinished(() -> {
@@ -1097,6 +1106,7 @@ class Client extends EventEmitter {
 			}
 
 			stream.emitSMupdates = true;
+			channelPinger.startTimer();
 			this.trigger("status/online", {});
 			this.trigger("chats/update", chats);
 			return EventHandled;
@@ -1150,6 +1160,7 @@ class Client extends EventEmitter {
 						sendPresence();
 						joinAllChannels();
 					}
+					channelPinger.startTimer();
 					this.trigger("status/online", {});
 					trace("SYNC: done");
 				});
@@ -2063,6 +2074,7 @@ class Client extends EventEmitter {
 
 	@:allow(borogove)
 	private function sendStanza(stanza:Stanza) {
+		channelPinger.pingDueInWindow();
 		if (stanza.attr.get("id") == null) stanza.attr.set("id", ID.unique());
 		stream.sendStanza(stanza);
 	}
diff --git a/borogove/Map.js.hx b/borogove/Map.js.hx
index f51fd05..de209aa 100644
--- a/borogove/Map.js.hx
+++ b/borogove/Map.js.hx
@@ -40,6 +40,10 @@ abstract Map<K,V>(NativeMap<K,V>) {
 		return [for (x in this) f(x)];
 	}
 
+	public inline function filter(f: V->Bool):Array<V> {
+		return [for (x in this) if (f(x)) x];
+	}
+
 	public inline function keyValueIterator():KeyValueIterator<K, V> {
 		return new HaxeKVIterator(this.entries());
 	}
diff --git a/borogove/OMEMO.hx b/borogove/OMEMO.hx
index 0664668..6d76637 100644
--- a/borogove/OMEMO.hx
+++ b/borogove/OMEMO.hx
@@ -834,7 +834,7 @@ class OMEMO {
 		return promPayload;
 	}
 
-	private function sendKeyExchange(deviceId:Int, addr:SignalProtocolAddress) {
+	private function sendKeyExchange(deviceId:Int, addr:SignalProtocolAddress):Promise<Stanza> {
 		trace("OMEMO: Preparing key exchange stanza...");
 		final emptyPayload = Bytes.alloc(32).toString();
 		final promEncryptedMessage = encryptPayloadWithNewKey(emptyPayload);
diff --git a/test/TestAll.hx b/test/TestAll.hx
index 2cffc25..73384e9 100644
--- a/test/TestAll.hx
+++ b/test/TestAll.hx
@@ -25,6 +25,7 @@ class TestAll {
 			new TestJID(),
 			new TestMember(),
 			new TestMemberUpdate(),
+			new TestChannelPinger(),
 			new TestMucSettingsCommand(),
 			new TestPresence(),
 			new TestReaction(),
diff --git a/test/TestChannelPinger.hx b/test/TestChannelPinger.hx
new file mode 100644
index 0000000..8478ed8
--- /dev/null
+++ b/test/TestChannelPinger.hx
@@ -0,0 +1,158 @@
+package test;
+
+import borogove.ChannelPinger;
+import borogove.Chat.Channel;
+import borogove.Chat.UiState;
+import borogove.EventEmitter.EventResult;
+import borogove.JID;
+import borogove.Stanza;
+import borogove.persistence.Dummy;
+import utest.Assert;
+
+@:access(borogove)
+class TestChannelPinger extends utest.Test {
+	private var client: borogove.Client;
+
+	public function setup() {
+		client = new borogove.Client("test@example.com", new Dummy());
+	}
+
+	public function testStartTimerErrorsIfCalledTwice() {
+		final pinger = new ChannelPinger();
+		pinger.startTimer();
+		Assert.raises(pinger.startTimer);
+	}
+
+	public function testScheduleAddsChannel() {
+		final now = 1000.0;
+		final channel = channel(client, "room@example.com");
+		final pinger = new ChannelPinger(() -> now);
+
+		Assert.isNull(pinger.scheduledChannels[channel.chatId]);
+
+		pinger.schedule(channel);
+
+		final scheduled = pinger.scheduledChannels[channel.chatId];
+		Assert.equals(channel, scheduled.channel);
+		Assert.equals(now + ChannelPinger.PING_INTERVAL_MS, scheduled.deadline);
+	}
+
+	public function testScheduleUpdatesDeadline() {
+		var now = 1000.0;
+		final channel = channel(client, "room@example.com");
+		final pinger = new ChannelPinger(() -> now);
+
+		pinger.schedule(channel);
+		Assert.equals(
+			now + ChannelPinger.PING_INTERVAL_MS,
+			pinger.scheduledChannels[channel.chatId].deadline,
+		);
+
+		now += 1000;
+
+		pinger.schedule(channel);
+		Assert.equals(
+			now + ChannelPinger.PING_INTERVAL_MS,
+			pinger.scheduledChannels[channel.chatId].deadline,
+		);
+	}
+
+	public function testScheduleAcceptsCustomDeadline() {
+		final channel = channel(client, "room@example.com");
+		final pinger = new ChannelPinger();
+		final deadline = 5000;
+
+		pinger.schedule(channel, deadline);
+
+		Assert.equals(
+			deadline,
+			pinger.scheduledChannels[channel.chatId].deadline,
+		);
+	}
+
+	public function testScheduleOnlySchedulesOpenChannels() {
+		final channel = channel(client, "room@example.com", Closed);
+		final pinger = new ChannelPinger();
+		pinger.schedule(channel);
+
+		Assert.isNull(pinger.scheduledChannels[channel.chatId]);
+	}
+
+	public function testRemoveRemovesScheduledChannel() {
+		final channel = channel(client, "room@example.com");
+		final pinger = new ChannelPinger();
+
+		pinger.schedule(channel);
+		Assert.notNull(pinger.scheduledChannels[channel.chatId]);
+
+		pinger.remove(channel);
+		Assert.isNull(pinger.scheduledChannels[channel.chatId]);
+	}
+
+	public function testPingPastDue() {
+		final now = 1000.0;
+		final pastDue = channel(client, "room1@example.com");
+		final dueNow = channel(client, "room2@example.com");
+		final dueInFuture = channel(client, "room3@example.com");
+		final pinger = new ChannelPinger(() -> now);
+
+		pinger.schedule(pastDue, now - 1);
+		pinger.schedule(dueNow, now);
+		pinger.schedule(dueInFuture, now + 1);
+
+		final pinged = capturePings(pinger.pingPastDue);
+
+		assertSameList([pastDue.chatId, dueNow.chatId], pinged);
+	}
+
+	public function testPingDueInWindowSendsEverythingIn60SecondsIfSomeIn30Seconds() {
+		final now = 1000.0;
+		final dueInNext30 = channel(client, "room1@example.com");
+		final dueInNext60 = channel(client, "room2@example.com");
+		final dueGreaterThan60 = channel(client, "room3@example.com");
+		final pinger = new ChannelPinger(() -> now);
+
+		pinger.schedule(dueInNext30, now + 30 * 1000);
+		pinger.schedule(dueInNext60, now + 60 * 1000);
+		pinger.schedule(dueGreaterThan60, now + 60 * 1000 + 1);
+
+		final pinged = capturePings(pinger.pingDueInWindow);
+
+		assertSameList([dueInNext30.chatId, dueInNext60.chatId], pinged);
+	}
+
+	public function testPingDueInWindowDoesNothingIfNothingDueInNext30() {
+		final now = 1000.0;
+		final room = channel(client, "room@example.com");
+		final pinger = new ChannelPinger(() -> now);
+		pinger.schedule(room, now + 30 * 1000 + 1);
+
+		final pinged = capturePings(pinger.pingDueInWindow);
+
+		Assert.same([], pinged);
+	}
+
+	private function channel(client, id: String, state: UiState = Open): Channel {
+		return new Channel(client, client.stream, client.persistence, id, state);
+	}
+
+	private function capturePings(operation: Void->Void): Array<String> {
+		final pinged = [];
+		client.stream.on("sendStanza", (stanza: Stanza) -> {
+			if (stanza.name == "iq" && stanza.getChild("ping", "urn:xmpp:ping") != null) {
+				pinged.push(JID.parse(stanza.attr.get("to")).asBare().asString());
+			}
+			return EventHandled;
+		});
+
+		operation();
+
+		return pinged;
+	}
+
+	private function assertSameList<T>(expected: Array<T>, actual: Array<T>) {
+		expected.sort(Reflect.compare);
+		actual.sort(Reflect.compare);
+		Assert.same(expected, actual);
+	}
+}