From 9f2e5087ceb7e5675d4183c59a8ed18592a97d25 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Wed, 30 Sep 2026 16:08:36 +0000 Subject: [PATCH] Stop two servers taking turns claiming a contact's last sighting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A contact on two servers at once was marked seen by each in turn: a sighting announced a contact change, every connection re-applied its contacts, and each re-application recorded the contact on its own server — another change, round and round, rewriting contacts.txt every time. Re-applying now only marks a contact that was never seen; the last sighting moves when the client appears. Sightings no longer write the file on their own either: they reach it in one save half a minute later, or with the next edit's save, and are flushed when the client closes. Co-Authored-By: Claude Opus 5.5 --- .../com/ts3client/contacts/ContactStore.java | 31 +++++++++++++++++-- .../ts3client/net/TeamspeakConnection.java | 15 ++++++--- .../ts3client/contacts/ContactStoreTest.java | 22 +++++++++++++ .../main/java/com/ts3client/ui/MainFrame.java | 1 + 4 files changed, 62 insertions(+), 7 deletions(-) diff --git a/ts3-client/core/src/main/java/com/ts3client/contacts/ContactStore.java b/ts3-client/core/src/main/java/com/ts3client/contacts/ContactStore.java index a870fdd..223e00a 100644 --- a/ts3-client/core/src/main/java/com/ts3client/contacts/ContactStore.java +++ b/ts3-client/core/src/main/java/com/ts3client/contacts/ContactStore.java @@ -16,6 +16,10 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; /** * The contact list (TeamSpeak's "Friend/Foe manager"): who is a friend, who is @@ -32,6 +36,9 @@ import java.util.concurrent.CopyOnWriteArrayList; */ public final class ContactStore { + /** How long a sighting may wait before it is written. */ + static final long SEEN_SAVE_DELAY_S = 30; + /** Told after the list changed in any way; called on whatever thread made the change. */ public interface Listener { void onContactsChanged(); @@ -46,6 +53,13 @@ public final class ContactStore { /** Immutable snapshot for readers, replaced under the lock after every change. */ private volatile List snapshot = List.of(); private final List listeners = new CopyOnWriteArrayList<>(); + /** Set while a sighting waits for {@link #flush()}; any save takes it along. */ + private final AtomicBoolean savePending = new AtomicBoolean(); + private final ScheduledExecutorService saver = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "contacts-save"); + t.setDaemon(true); + return t; + }); ContactStore(File file) { this.file = file; @@ -89,6 +103,7 @@ public final class ContactStore { } public void save() { + savePending.set(false); List all = snapshot; StringBuilder sb = new StringBuilder(); for (Contact c : all) { @@ -211,7 +226,9 @@ public final class ContactStore { /** * Notes that a contact is online right now. A sighting within the same minute on the - * same server changes nothing, so a busy server does not rewrite the file per event. + * same server changes nothing. Sightings reach the file in one go, {@link + * #SEEN_SAVE_DELAY_S} later or with the next save, so a busy server does not rewrite it + * per event. */ public void markSeen(String uniqueId, String serverName, String serverAddress) { Contact c = find(uniqueId); @@ -226,11 +243,21 @@ public final class ContactStore { c.lastSeenServerName = orEmpty(serverName); c.lastSeenServerAddress = orEmpty(serverAddress); } - changed(); + if (savePending.compareAndSet(false, true)) saver.schedule(this::flush, SEEN_SAVE_DELAY_S, TimeUnit.SECONDS); + announce(); + } + + /** Writes sightings still waiting to be saved; for when the client goes away. */ + public void flush() { + if (savePending.get()) save(); } private void changed() { save(); + announce(); + } + + private void announce() { for (Listener l : listeners) l.onContactsChanged(); } diff --git a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java index d7fb929..9ad1f55 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java @@ -995,8 +995,7 @@ public final class TeamspeakConnection implements TS3Listener { /** * Applies a client's contact entry to its playback — the automatic mute and the - * volume modifier — and records the sighting. Called when the client appears and - * whenever its entry is edited. + * volume modifier — and records the sighting. Called when the client appears. */ void applyContact(ClientEntry client) { Contact contact = applyContactPlayback(client); @@ -1016,12 +1015,18 @@ public final class TeamspeakConnection implements TS3Listener { /** * Re-applies every contact entry to the clients on the server, after the list changed. - * A client that just became a contact is also marked as seen; the store only reports - * that as a change once, so the re-application it triggers stops there. + * A client that just became a contact is also marked as seen. One seen before keeps + * where it was seen: with it on two servers, each re-application would claim it for its + * own server, which is a change that triggers the other's re-application, and so on. */ public void applyContacts() { if (!connected) return; - for (ClientEntry c : model.allClients()) applyContact(c); + for (ClientEntry c : model.allClients()) { + Contact contact = applyContactPlayback(c); + if (contact != null && contact.lastSeen == null) { + contacts.markSeen(c.uniqueId, model.getServerName(), serverHost + ":" + serverPort); + } + } } /** Remembers a per-client volume, in the contact entry when the client has one. */ diff --git a/ts3-client/core/src/test/java/com/ts3client/contacts/ContactStoreTest.java b/ts3-client/core/src/test/java/com/ts3client/contacts/ContactStoreTest.java index 2f48a2d..11cb0fb 100644 --- a/ts3-client/core/src/test/java/com/ts3client/contacts/ContactStoreTest.java +++ b/ts3-client/core/src/test/java/com/ts3client/contacts/ContactStoreTest.java @@ -80,6 +80,28 @@ class ContactStoreTest { assertEquals("other:9987", a.lastSeenServerAddress); } + @Test + void sightingsWaitForTheNextSave() throws Exception { + File file = new File(dir, "s.txt"); + ContactStore store = new ContactStore(file); + store.add("a=", "A", ContactType.NEUTRAL, new ContactDefaults()); + long written = file.lastModified(); + String before = Files.readString(file.toPath(), StandardCharsets.UTF_8); + + store.markSeen("a=", "Server", "host:9987"); + assertEquals(before, Files.readString(file.toPath(), StandardCharsets.UTF_8)); + store.flush(); + assertTrue(Files.readString(file.toPath(), StandardCharsets.UTF_8).contains("LastSeenServerAddress=host:9987")); + + // An edit's save takes a waiting sighting along; the flush then has nothing left to write. + store.markSeen("a=", "Other", "other:9987"); + store.put(store.find("a=")); + assertTrue(Files.readString(file.toPath(), StandardCharsets.UTF_8).contains("LastSeenServerAddress=other:9987")); + assertTrue(file.setLastModified(written)); + store.flush(); + assertEquals(written, file.lastModified()); + } + @Test void mergeAddsNewAndOverwritesExisting() { ContactStore store = new ContactStore(new File(dir, "m.txt")); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java index e3e061b..a5d0268 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java @@ -766,6 +766,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { // Already shutting down (hook itself is running); nothing to remove. } sessions.shutdown(); + contacts.flush(); soundPlayer.shutdown(); hotkeys.dispose(); if (tray != null) tray.dispose();