Stop two servers taking turns claiming a contact's last sighting

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 16:08:36 +00:00
parent 1dda20d1b2
commit 9f2e5087ce
4 changed files with 62 additions and 7 deletions

View File

@@ -16,6 +16,10 @@ import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList; 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 * 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 { 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. */ /** Told after the list changed in any way; called on whatever thread made the change. */
public interface Listener { public interface Listener {
void onContactsChanged(); void onContactsChanged();
@@ -46,6 +53,13 @@ public final class ContactStore {
/** Immutable snapshot for readers, replaced under the lock after every change. */ /** Immutable snapshot for readers, replaced under the lock after every change. */
private volatile List<Contact> snapshot = List.of(); private volatile List<Contact> snapshot = List.of();
private final List<Listener> listeners = new CopyOnWriteArrayList<>(); private final List<Listener> 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) { ContactStore(File file) {
this.file = file; this.file = file;
@@ -89,6 +103,7 @@ public final class ContactStore {
} }
public void save() { public void save() {
savePending.set(false);
List<Contact> all = snapshot; List<Contact> all = snapshot;
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
for (Contact c : all) { 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 * 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) { public void markSeen(String uniqueId, String serverName, String serverAddress) {
Contact c = find(uniqueId); Contact c = find(uniqueId);
@@ -226,11 +243,21 @@ public final class ContactStore {
c.lastSeenServerName = orEmpty(serverName); c.lastSeenServerName = orEmpty(serverName);
c.lastSeenServerAddress = orEmpty(serverAddress); 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() { private void changed() {
save(); save();
announce();
}
private void announce() {
for (Listener l : listeners) l.onContactsChanged(); for (Listener l : listeners) l.onContactsChanged();
} }

View File

@@ -995,8 +995,7 @@ public final class TeamspeakConnection implements TS3Listener {
/** /**
* Applies a client's contact entry to its playback — the automatic mute and the * 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 * volume modifier — and records the sighting. Called when the client appears.
* whenever its entry is edited.
*/ */
void applyContact(ClientEntry client) { void applyContact(ClientEntry client) {
Contact contact = applyContactPlayback(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. * 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 * A client that just became a contact is also marked as seen. One seen before keeps
* that as a change once, so the re-application it triggers stops there. * 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() { public void applyContacts() {
if (!connected) return; 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. */ /** Remembers a per-client volume, in the contact entry when the client has one. */

View File

@@ -80,6 +80,28 @@ class ContactStoreTest {
assertEquals("other:9987", a.lastSeenServerAddress); 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 @Test
void mergeAddsNewAndOverwritesExisting() { void mergeAddsNewAndOverwritesExisting() {
ContactStore store = new ContactStore(new File(dir, "m.txt")); ContactStore store = new ContactStore(new File(dir, "m.txt"));

View File

@@ -766,6 +766,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
// Already shutting down (hook itself is running); nothing to remove. // Already shutting down (hook itself is running); nothing to remove.
} }
sessions.shutdown(); sessions.shutdown();
contacts.flush();
soundPlayer.shutdown(); soundPlayer.shutdown();
hotkeys.dispose(); hotkeys.dispose();
if (tray != null) tray.dispose(); if (tray != null) tray.dispose();