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.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<Contact> snapshot = List.of();
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) {
this.file = file;
@@ -89,6 +103,7 @@ public final class ContactStore {
}
public void save() {
savePending.set(false);
List<Contact> 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();
}

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
* 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. */

View File

@@ -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"));

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.
}
sessions.shutdown();
contacts.flush();
soundPlayer.shutdown();
hotkeys.dispose();
if (tray != null) tray.dispose();