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