Publish the channel tree's lists whole instead of refilling them

buildTree() cleared and refilled the children and client lists inside
the shared channel nodes, so a UI still walking the previous tree could
see them change under it. It now builds new lists and publishes them
unmodifiable. The node fields, written by the event and audio threads
and read by the UI, are volatile.

Whether a channel is full is now counted from the model, rather than
from a tree that may not have been rebuilt since the last join.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 10:38:25 +00:00
parent 665316d515
commit 67f8f46cab
4 changed files with 79 additions and 62 deletions

View File

@@ -3,26 +3,30 @@ package com.ts3client.net;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
/** Mutable view-model of a TeamSpeak channel. */ /**
* View-model of a TeamSpeak channel. The event thread updates it while the UI reads it, so
* every field is volatile; {@link #children} and {@link #clients} are replaced whole on each
* {@link ServerModel#buildTree()}, never changed in place.
*/
public final class ChannelNode { public final class ChannelNode {
public final int id; public final int id;
public int parentId; public volatile int parentId;
public int order; public volatile int order;
public String name; public volatile String name;
public String topic = ""; public volatile String topic = "";
public String description = ""; public volatile String description = "";
public boolean descriptionLoaded; public volatile boolean descriptionLoaded;
public boolean hasPassword; public volatile boolean hasPassword;
public boolean permanent; public volatile boolean permanent;
public int maxClients = -1; public volatile int maxClients = -1;
/** Id of the channel's custom icon in the server's file repository, or 0 for none. */ /** Id of the channel's custom icon in the server's file repository, or 0 for none. */
public long iconId; public volatile long iconId;
/** Whether the server sends us this channel's client list; set from the subscription events. */ /** Whether the server sends us this channel's client list; set from the subscription events. */
public boolean subscribed; public volatile boolean subscribed;
/** Populated when the tree is rebuilt. */ /** As of the last {@link ServerModel#buildTree()}; unmodifiable. */
public final List<ChannelNode> children = new ArrayList<>(); public volatile List<ChannelNode> children = List.of();
public final List<ClientEntry> clients = new ArrayList<>(); public volatile List<ClientEntry> clients = List.of();
public ChannelNode(int id, String name) { public ChannelNode(int id, String name) {
this.id = id; this.id = id;

View File

@@ -1,38 +1,41 @@
package com.ts3client.net; package com.ts3client.net;
/** Mutable view-model of a connected client. */ /**
* View-model of a connected client. The event and audio threads update it while the UI reads
* it, so every field is volatile; {@link #serverGroupIds} is replaced, never changed in place.
*/
public final class ClientEntry { public final class ClientEntry {
public final int id; public final int id;
public int channelId; public volatile int channelId;
public String nickname; public volatile String nickname;
public String uniqueId = ""; public volatile String uniqueId = "";
public int databaseId; public volatile int databaseId;
public int type; // 0 = normal voice client, 1 = server-query public volatile int type; // 0 = normal voice client, 1 = server-query
public int talkPower; public volatile int talkPower;
public int[] serverGroupIds = new int[0]; public volatile int[] serverGroupIds = new int[0];
public int channelGroupId; public volatile int channelGroupId;
// Filled on demand from clientinfo. // Filled on demand from clientinfo.
public String platform = ""; public volatile String platform = "";
public String version = ""; public volatile String version = "";
public long idleTimeMs; public volatile long idleTimeMs;
public String description = ""; public volatile String description = "";
/** MD5 of the client's avatar ({@code client_flag_avatar}); empty when they have none. */ /** MD5 of the client's avatar ({@code client_flag_avatar}); empty when they have none. */
public String avatarFlag = ""; public volatile String avatarFlag = "";
public boolean talking; public volatile boolean talking;
public boolean inputMuted; // microphone muted (client_input_muted) public volatile boolean inputMuted; // microphone muted (client_input_muted)
public boolean outputMuted; // speakers muted / deafened (client_output_muted) public volatile boolean outputMuted; // speakers muted / deafened (client_output_muted)
/** False while the capture device is unavailable: another tab holds the microphone. */ /** False while the capture device is unavailable: another tab holds the microphone. */
public boolean inputHardware = true; public volatile boolean inputHardware = true;
/** False while the playback device is unavailable. */ /** False while the playback device is unavailable. */
public boolean outputHardware = true; public volatile boolean outputHardware = true;
public boolean away; public volatile boolean away;
/** The message published with the away state, empty when there is none. */ /** The message published with the away state, empty when there is none. */
public String awayMessage = ""; public volatile String awayMessage = "";
public boolean channelCommander; public volatile boolean channelCommander;
public boolean self; public volatile boolean self;
public ClientEntry(int id, String nickname) { public ClientEntry(int id, String nickname) {
this.id = id; this.id = id;
@@ -50,25 +53,27 @@ public final class ClientEntry {
/** Adds a server group id, if not already present. */ /** Adds a server group id, if not already present. */
public void addServerGroup(int groupId) { public void addServerGroup(int groupId) {
for (int id : serverGroupIds) if (id == groupId) return; int[] ids = serverGroupIds;
int[] updated = java.util.Arrays.copyOf(serverGroupIds, serverGroupIds.length + 1); for (int id : ids) if (id == groupId) return;
updated[serverGroupIds.length] = groupId; int[] updated = java.util.Arrays.copyOf(ids, ids.length + 1);
updated[ids.length] = groupId;
serverGroupIds = updated; serverGroupIds = updated;
} }
/** Removes a server group id, if present. */ /** Removes a server group id, if present. */
public void removeServerGroup(int groupId) { public void removeServerGroup(int groupId) {
int[] ids = serverGroupIds;
int index = -1; int index = -1;
for (int i = 0; i < serverGroupIds.length; i++) { for (int i = 0; i < ids.length; i++) {
if (serverGroupIds[i] == groupId) { if (ids[i] == groupId) {
index = i; index = i;
break; break;
} }
} }
if (index < 0) return; if (index < 0) return;
int[] updated = new int[serverGroupIds.length - 1]; int[] updated = new int[ids.length - 1];
System.arraycopy(serverGroupIds, 0, updated, 0, index); System.arraycopy(ids, 0, updated, 0, index);
System.arraycopy(serverGroupIds, index + 1, updated, index, serverGroupIds.length - index - 1); System.arraycopy(ids, index + 1, updated, index, ids.length - index - 1);
serverGroupIds = updated; serverGroupIds = updated;
} }
} }

View File

@@ -2,15 +2,16 @@ package com.ts3client.net;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
* Thread-safe holder for the current server state (channels + clients). * The current server state (channels + clients), updated from ts3j's event thread and read
* * from the UI. The maps are guarded by the instance; the {@link ChannelNode}s and
* <p>Mutated from ts3j's event thread and read from the Swing EDT while building * {@link ClientEntry}s handed out are live, with volatile fields, so a reader sees each value
* the tree, so all access is synchronised on the instance. * as it is now but not several of them changed together.
*/ */
public final class ServerModel { public final class ServerModel {
@@ -198,7 +199,12 @@ public final class ServerModel {
public synchronized boolean canJoinChannel(int channelId) { public synchronized boolean canJoinChannel(int channelId) {
ChannelNode channel = channels.get(channelId); ChannelNode channel = channels.get(channelId);
if (channel == null || !channel.subscribed) return false; if (channel == null || !channel.subscribed) return false;
return channel.maxClients < 0 || channel.clients.size() < channel.maxClients; if (channel.maxClients < 0) return true;
int inside = 0;
for (ClientEntry c : clients.values()) {
if (c.channelId == channelId && !c.isQuery()) inside++;
}
return inside < channel.maxClients;
} }
public synchronized String getServerName() { public synchronized String getServerName() {
@@ -380,24 +386,23 @@ public final class ServerModel {
* @return the list of root channels (parentId == 0) * @return the list of root channels (parentId == 0)
*/ */
public synchronized List<ChannelNode> buildTree() { public synchronized List<ChannelNode> buildTree() {
// Reset transient child/client lists. // Fresh lists, published whole: whoever still walks the previous tree keeps a consistent one.
for (ChannelNode c : channels.values()) { Map<Integer, List<ChannelNode>> children = new HashMap<>();
c.children.clear(); Map<Integer, List<ClientEntry>> inside = new HashMap<>();
c.clients.clear();
}
List<ChannelNode> roots = new ArrayList<>(); List<ChannelNode> roots = new ArrayList<>();
for (ChannelNode c : channels.values()) { for (ChannelNode c : channels.values()) {
ChannelNode parent = channels.get(c.parentId); ChannelNode parent = channels.get(c.parentId);
if (c.parentId == 0 || parent == null) { if (c.parentId == 0 || parent == null) {
roots.add(c); roots.add(c);
} else { } else {
parent.children.add(c); children.computeIfAbsent(parent.id, id -> new ArrayList<>()).add(c);
} }
} }
for (ClientEntry cl : clients.values()) { for (ClientEntry cl : clients.values()) {
if (cl.isQuery()) continue; // hide server-query clients from the tree if (cl.isQuery()) continue; // hide server-query clients from the tree
ChannelNode ch = channels.get(cl.channelId); if (channels.containsKey(cl.channelId)) {
if (ch != null) ch.clients.add(cl); inside.computeIfAbsent(cl.channelId, id -> new ArrayList<>()).add(cl);
}
} }
Comparator<ClientEntry> byClient = Comparator Comparator<ClientEntry> byClient = Comparator
@@ -406,8 +411,12 @@ public final class ServerModel {
sortSiblings(roots); sortSiblings(roots);
for (ChannelNode c : channels.values()) { for (ChannelNode c : channels.values()) {
sortSiblings(c.children); List<ChannelNode> below = children.getOrDefault(c.id, List.of());
c.clients.sort(byClient); sortSiblings(below);
c.children = List.copyOf(below);
List<ClientEntry> members = inside.getOrDefault(c.id, List.of());
if (members.size() > 1) members.sort(byClient);
c.clients = List.copyOf(members);
} }
return roots; return roots;
} }

View File

@@ -53,7 +53,6 @@ class ServerModelRulesTest {
channel.subscribed = true; channel.subscribed = true;
assertTrue(model.canJoinChannel(1)); assertTrue(model.canJoinChannel(1));
channel.maxClients = 1; channel.maxClients = 1;
channel.clients.add(model.getClient(5));
assertFalse(model.canJoinChannel(1), "full"); assertFalse(model.canJoinChannel(1), "full");
assertFalse(model.canJoinChannel(2), "unknown"); assertFalse(model.canJoinChannel(2), "unknown");
} }