diff --git a/CHANGELOG.md b/CHANGELOG.md index efd207dc..0a243709 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/nostr-java-client/pom.xml b/nostr-java-client/pom.xml index 2f1f6a46..5c07b6fc 100644 --- a/nostr-java-client/pom.xml +++ b/nostr-java-client/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.0.5 + 2.0.7 ../pom.xml diff --git a/nostr-java-client/src/main/java/nostr/client/springwebsocket/NostrRelayClient.java b/nostr-java-client/src/main/java/nostr/client/springwebsocket/NostrRelayClient.java index 858b962a..15853f49 100644 --- a/nostr-java-client/src/main/java/nostr/client/springwebsocket/NostrRelayClient.java +++ b/nostr-java-client/src/main/java/nostr/client/springwebsocket/NostrRelayClient.java @@ -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(); diff --git a/nostr-java-client/src/test/java/nostr/client/springwebsocket/NostrRelayClientCloseWriteRaceTest.java b/nostr-java-client/src/test/java/nostr/client/springwebsocket/NostrRelayClientCloseWriteRaceTest.java index 7ddc6b93..eb0d61f4 100644 --- a/nostr-java-client/src/test/java/nostr/client/springwebsocket/NostrRelayClientCloseWriteRaceTest.java +++ b/nostr-java-client/src/test/java/nostr/client/springwebsocket/NostrRelayClientCloseWriteRaceTest.java @@ -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 diff --git a/nostr-java-core/pom.xml b/nostr-java-core/pom.xml index d38bc5e9..d05aec6b 100644 --- a/nostr-java-core/pom.xml +++ b/nostr-java-core/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.0.5 + 2.0.7 ../pom.xml diff --git a/nostr-java-event/pom.xml b/nostr-java-event/pom.xml index ba383a77..6ea8bd83 100644 --- a/nostr-java-event/pom.xml +++ b/nostr-java-event/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.0.5 + 2.0.7 ../pom.xml diff --git a/nostr-java-identity/pom.xml b/nostr-java-identity/pom.xml index 7001e999..acfaced8 100644 --- a/nostr-java-identity/pom.xml +++ b/nostr-java-identity/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.0.5 + 2.0.7 ../pom.xml diff --git a/pom.xml b/pom.xml index 4b0f4314..e5c21e39 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ xyz.tcheeric nostr-java - 2.0.5 + 2.0.7 pom nostr-java