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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ The format is inspired by Keep a Changelog, and this project adheres to semantic

## [Unreleased]

## [2.0.7] - 2026-05-27

### Fixed
- Closed a TOCTOU window in the 2.0.6 `send()` `isOpen()` guard (PR #526 review). The check was performed at the top of `send()`, *before* `sendFrameGated()` acquired the `sessionGate` read lock — so a concurrent `close()` could acquire the write lock, close the session, and release it between the check and the actual `sendMessage()`, still letting the frame reach a closed session. Moved the `isOpen()` check inside `sendFrameGated()`, immediately after the read lock is taken, so the open-check and the write are now atomic with respect to `closeGated()` (which holds the write lock). 2.0.6 already handled the dominant case (the session already closed when `send()` is invoked); this closes the narrow in-flight-close window.

## [2.0.6] - 2026-05-27

### Fixed
- `NostrRelayClient.send()` now guards on `clientSession.isOpen()` before writing, mirroring `subscribe()`. A per-subscription Nostr `CLOSE` issued while a relay connection was being torn down previously reached Tomcat's `sendText`, where `WsRemoteEndpointImplBase.sendMessageBlockInternal` invokes `doClose()` mid-write and emits a WS CLOSE frame while the text write is still pending on the async channel — throwing `IllegalStateException: Concurrent write operations are not permitted`, which surfaced via `handleTransportError` as a transport-error reconnect storm during subscription teardown of a breaking connection (spec-026). The application-level locks (the decorator, the `sessionGate`, and the upstream adapter `sendLock`) cannot prevent this because it is Tomcat closing the session *inside* a single in-progress send, not two application threads racing. `send()` now fails fast with `IOException("WebSocket session is closed")` on a closed session — the doomed write never enters Tomcat's close-mid-write path. +regression test `send_onClosedSession_failsFastWithoutDelegateWrite`.

## [2.0.5] - 2026-05-26

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion nostr-java-client/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<parent>
<groupId>xyz.tcheeric</groupId>
<artifactId>nostr-java</artifactId>
<version>2.0.5</version>
<version>2.0.7</version>
<relativePath>../pom.xml</relativePath>
</parent>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -717,6 +717,17 @@ public void close() throws IOException {
private void sendFrameGated(String json) throws IOException {
sessionGate.readLock().lock();
try {
// The isOpen() check MUST live inside the read lock so it is mutually
// exclusive with closeGated() (which holds the write lock). A check
// outside the lock leaves a TOCTOU window: a concurrent close() could
// close + release the session between the check and this write, letting
// the frame reach an already-closed session and trip Tomcat's
// close-mid-write race ("Concurrent write operations are not permitted").
// Per-subscription CLOSEs issued while a relay connection is being torn
// down are the dominant trigger. (spec-026)
if (!clientSession.isOpen()) {
throw new IOException("WebSocket session is closed");
}
clientSession.sendMessage(new TextMessage(json));
} finally {
sessionGate.readLock().unlock();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,24 @@ void sendTimeout_routesCloseThroughCloseStatus_neverNoArgClose() throws Exceptio
verify(raw, never()).close();
}

// ---- Guard: send() on an already-closed session must fail fast without
// reaching the delegate, so it never enters Tomcat's sendText →
// doClose-mid-write → "Concurrent write operations are not permitted"
// path (the per-subscription CLOSE-on-a-dying-connection trigger). ----
@Test
void send_onClosedSession_failsFastWithoutDelegateWrite() throws Exception {
WebSocketSession raw = Mockito.mock(WebSocketSession.class);
Mockito.when(raw.isOpen()).thenReturn(false); // session already closed/closing

NostrRelayClient client =
NostrRelayClient.forTestWithDecoratedSession(raw, TEST_AWAIT_TIMEOUT_MS);

IOException ex = assertThrows(IOException.class, () -> client.send(REQ));
assertTrue(ex.getMessage().contains("closed"),
"expected a closed-session IOException, was: " + ex.getMessage());
verify(raw, never()).sendMessage(any(TextMessage.class));
}

// ---- Serialisation: an in-flight subscribe write and a concurrent close
// must never overlap on the delegate. ----
@Test
Expand Down
2 changes: 1 addition & 1 deletion nostr-java-core/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<parent>
<groupId>xyz.tcheeric</groupId>
<artifactId>nostr-java</artifactId>
<version>2.0.5</version>
<version>2.0.7</version>
<relativePath>../pom.xml</relativePath>
</parent>

Expand Down
2 changes: 1 addition & 1 deletion nostr-java-event/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<parent>
<groupId>xyz.tcheeric</groupId>
<artifactId>nostr-java</artifactId>
<version>2.0.5</version>
<version>2.0.7</version>
<relativePath>../pom.xml</relativePath>
</parent>

Expand Down
2 changes: 1 addition & 1 deletion nostr-java-identity/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<parent>
<groupId>xyz.tcheeric</groupId>
<artifactId>nostr-java</artifactId>
<version>2.0.5</version>
<version>2.0.7</version>
<relativePath>../pom.xml</relativePath>
</parent>

Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

<groupId>xyz.tcheeric</groupId>
<artifactId>nostr-java</artifactId>
<version>2.0.5</version>
<version>2.0.7</version>
<packaging>pom</packaging>

<name>nostr-java</name>
Expand Down