Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

## Unreleased

- Reclaimed empty rooms on last leave and on disconnect, announced them with the new `ROOM_REMOVED`
frame, and taught the console and Swing clients to drop reclaimed rooms.
- Bounded distinct rooms with a configurable `maxRooms` limit (default 256, CLI `--max-rooms`) and
answered over-limit `ROOM_JOIN` with an explicit `ERROR`.
- Added per-connection token buckets for inbound frames and room creation that reply `ERROR` instead
of dropping the connection.

## 1.6.1 - 2026-06-19

- Hardened account storage, history migration, TLS socket handling, and integration coverage after repository audit.
Expand Down
18 changes: 14 additions & 4 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ clients <-> ChatConnection <-> ChatProtocol <-> ChatServer
room history is replayed after `ROOM_JOINED`.
9. Admin users can send `/health` to receive a private server status frame.
10. Closing or failed connections are removed and announced with `USER_REMOVED`.
11. A non-default room is reclaimed as soon as its last member leaves or disconnects, and the
reclamation is announced with `ROOM_REMOVED`.

## Message Frames

Expand All @@ -34,7 +36,7 @@ Frames are one-line UTF-8 JSON objects serialized by `ChatProtocol`.
`PRIVATE_TEXT`.
- `sender` carries the author for text frames; clients own display formatting.
- `room` carries room routing for `ROOM_TEXT`, `ROOM_JOIN`, `ROOM_LEAVE`, `ROOM_ADDED`,
`ROOM_JOINED`, and `ROOM_LEFT`.
`ROOM_REMOVED`, `ROOM_JOINED`, and `ROOM_LEFT`.
- `recipient` carries the target user for `PRIVATE_TEXT`.
- Clients may supply `timestamp` and `messageId`, but the server replaces both when it normalizes a
room or private message so clients cannot forge identity or ordering metadata.
Expand All @@ -50,11 +52,15 @@ Frames are one-line UTF-8 JSON objects serialized by `ChatProtocol`.
- `port` - TCP port, default `1500`.
- `bindAddress` - listen address, default loopback-only `127.0.0.1`.
- `maxClients` - maximum concurrent client handler threads, default `100`.
- `maxRooms` - maximum number of distinct rooms, default `256`, CLI `--max-rooms`.
- `handshakeTimeout` - maximum time to complete username registration, default `10s`.
- `readTimeout` - idle socket read timeout after handshake, default `5m`.
- `historyFile` / `historyLimit` / `historyReplayLimit` - optional replayable JSONL history.
- `accountFile` - optional account registry.
- `tls` - optional JSSE TLS server socket configuration.
- `rateLimit` - per-connection token buckets for inbound frames (default burst `60`, `20/s`) and for
room creation (default burst `10`, `0.5/s`); exhausted buckets answer `ERROR` instead of closing
the connection.

The legacy `new ChatServer(int port)` constructor delegates to `ChatServerConfig.ofPort(port)`.

Expand Down Expand Up @@ -97,9 +103,13 @@ The Swing model stores a bounded local timeline for the current app session. Tex
deduplicated by `messageId`, own messages are rendered as `Вы`, and user add/remove protocol events
are appended as service events instead of ordinary chat text.

Room membership lives on the server. `general` always exists, room creation is idempotent through
`ROOM_JOIN`, distinct rooms are capped at 1,000, and clients that try to send to a room before
joining receive an explicit `ERROR`.
Room membership lives on the server. `general` always exists and is never reclaimed, room creation is
idempotent through `ROOM_JOIN`, distinct rooms are capped by `maxRooms`, and clients that try to send
to a room before joining receive an explicit `ERROR`. Every other room is reclaimed and announced
with `ROOM_REMOVED` once its last member leaves or disconnects, so room names cannot accumulate for
the lifetime of the process. Membership and reclamation run under a dedicated rooms monitor that
holds no I/O and is never nested inside the sessions monitor, so the cap stays exact and a room
cannot disappear between a joiner observing it and joining it.

## History Store

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package dev.krotname.networkchat;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
Expand Down Expand Up @@ -141,6 +142,53 @@ void joiningAndLeavingRoomsProducesRoomEvents() throws Exception {

alice.leaveRoom("dev");
awaitProtocolEvent(alice, MessageType.ROOM_LEFT, "dev");
awaitProtocolEvent(alice, MessageType.ROOM_REMOVED, "dev");

assertFalse(server.getRooms().contains("dev"));
assertTrue(server.getRooms().contains(ChatMessage.GENERAL_ROOM));
}
}
}

@Test
void roomsAreReclaimedWhenTheirLastMemberDisconnects() throws Exception {
int port = 0;
try (ChatServer server = new ChatServer(port)) {
server.start();
server.awaitStarted();
port = server.getPort();

try (TestClient observer = new TestClient("observer", port)) {
observer.connect();
try (TestClient alice = new TestClient("alice", port)) {
alice.connect();
alice.joinRoom("dev");
awaitProtocolEvent(observer, MessageType.ROOM_ADDED, "dev");
}

awaitProtocolEvent(observer, MessageType.ROOM_REMOVED, "dev");
assertFalse(server.getRooms().contains("dev"));
}
}
}

@Test
void roomCreationBeyondTheConfiguredCapIsRejected() throws Exception {
int port = 0;
try (ChatServer server = new ChatServer(ChatServerConfig.ofPort(port).withMaxRooms(2))) {
server.start();
server.awaitStarted();
port = server.getPort();

try (TestClient alice = new TestClient("alice", port)) {
alice.connect();
alice.joinRoom("dev");
awaitProtocolEvent(alice, MessageType.ROOM_JOINED, "dev");

alice.joinRoom("overflow");
awaitProtocolEvent(alice, MessageType.ERROR, "Room limit reached");

assertFalse(server.getRooms().contains("overflow"));
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,7 @@
case USER_ADDED -> informAboutAddingNewUser(message.data());
case USER_REMOVED -> informAboutDeletingNewUser(message.data());
case ROOM_ADDED -> informAboutRoomAdded(message.room());
case ROOM_REMOVED -> informAboutRoomRemoved(message.room());
case ROOM_JOINED -> informAboutRoomJoined(message.room());
case ROOM_LEFT -> informAboutRoomLeft(message.room());
case TEXT, ROOM_TEXT, PRIVATE_TEXT -> processIncomingMessage(message);
Expand All @@ -399,6 +400,10 @@
System.out.printf("Комната доступна: %s%n", roomName);
}

protected void informAboutRoomRemoved(String roomName) {
System.out.printf("Комната закрыта: %s%n", roomName);

Check warning on line 404 in src/main/java/dev/krotname/networkchat/client/ChatClient.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this use of System.out by a logger.

See more on https://sonarcloud.io/project/issues?id=krotname_Network-Chat&issues=AZ-NoJaekL2BOW0UHmRE&open=AZ-NoJaekL2BOW0UHmRE&pullRequest=34
}

protected void informAboutRoomJoined(String roomName) {
System.out.printf("Вы вошли в комнату: %s%n", roomName);
}
Expand Down Expand Up @@ -457,7 +462,7 @@
throw new IOException("Server sent an invalid user event");
}
}
case ROOM_ADDED, ROOM_JOINED, ROOM_LEFT -> {
case ROOM_ADDED, ROOM_REMOVED, ROOM_JOINED, ROOM_LEFT -> {
if (!isValidRoomName(message.room())) {
throw new IOException("Server sent an invalid room event");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,19 @@ protected void informAboutRoomAdded(String roomName) {
view.refreshRooms(currentRoom);
}

@Override
protected void informAboutRoomRemoved(String roomName) {
if (!model.removeRoom(roomName)) {
return;
}
if (roomName.equals(currentRoom)) {
currentRoom = ChatMessage.GENERAL_ROOM;
}
model.addServiceMessage("Комната " + roomName + " закрыта");
view.refreshRooms(currentRoom);
view.refreshMessages();
}

@Override
protected void informAboutRoomJoined(String roomName) {
model.joinRoom(roomName);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,12 @@ public synchronized void addRoom(String roomName) {
allRoomNames.add(roomName);
}

/** Drops a room reclaimed by the server so the room list does not go stale. */
public synchronized boolean removeRoom(String roomName) {
joinedRoomNames.remove(roomName);
return allRoomNames.remove(roomName);
}

public synchronized void joinRoom(String roomName) {
allRoomNames.add(roomName);
joinedRoomNames.add(roomName);
Expand Down
Loading
Loading