From 7906bb22956919aba90348a77a2583cc4cad2987 Mon Sep 17 00:00:00 2001 From: mleku Date: Tue, 28 Oct 2025 18:42:18 +0000 Subject: [PATCH] Add WebSocket Connection Testing Scripts - Introduced two new test scripts: `test-relay-connection.js` and `test-websocket-close.js` to verify WebSocket connection stability and closure behavior. - `test-relay-connection.js` tests multiple connections, monitors their open/close events, and analyzes premature closures. - `test-websocket-close.js` focuses on connection closure issues with concurrent connections and logs results for connected, closed, and error states. - Both scripts utilize the NostrWebSocket from the @nostr-dev-kit/ndk package for testing purposes. --- app/handle-websocket.go | 20 ++++- test-relay-connection.js | 167 +++++++++++++++++++++++++++++++++++++++ test-websocket-close.js | 57 +++++++++++++ 3 files changed, 240 insertions(+), 4 deletions(-) create mode 100755 test-relay-connection.js create mode 100755 test-websocket-close.js diff --git a/app/handle-websocket.go b/app/handle-websocket.go index 4cc0fbd..5c6cb04 100644 --- a/app/handle-websocket.go +++ b/app/handle-websocket.go @@ -56,6 +56,8 @@ func (s *Server) HandleWebsocket(w http.ResponseWriter, r *http.Request) { return } whitelist: + // Create an independent context for this connection + // This context will be cancelled when the connection closes or server shuts down ctx, cancel := context.WithCancel(s.Ctx) defer cancel() var err error @@ -107,7 +109,8 @@ whitelist: log.D.F("AUTH challenge sent successfully to %s", remote) } ticker := time.NewTicker(DefaultPingWait) - go s.Pinger(ctx, conn, ticker, cancel) + // Don't pass cancel to Pinger - it should not be able to cancel the connection context + go s.Pinger(ctx, conn, ticker) defer func() { log.D.F("closing websocket connection from %s", remote) @@ -117,7 +120,11 @@ whitelist: // Cancel all subscriptions for this connection log.D.F("cancelling subscriptions for %s", remote) - listener.publishers.Receive(&W{Cancel: true}) + listener.publishers.Receive(&W{ + Cancel: true, + Conn: listener.conn, + remote: listener.remote, + }) // Log detailed connection statistics dur := time.Since(listener.startTime) @@ -155,6 +162,11 @@ whitelist: typ, msg, err = conn.Read(ctx) if err != nil { + // Check if the error is due to context cancellation + if err == context.Canceled || strings.Contains(err.Error(), "context canceled") { + log.T.F("connection from %s cancelled (context done): %v", remote, err) + return + } if strings.Contains( err.Error(), "use of closed network connection", ) { @@ -233,12 +245,12 @@ whitelist: func (s *Server) Pinger( ctx context.Context, conn *websocket.Conn, ticker *time.Ticker, - cancel context.CancelFunc, ) { defer func() { log.D.F("pinger shutting down") - cancel() ticker.Stop() + // DO NOT call cancel here - the pinger should not be able to cancel the connection context + // The connection handler will cancel the context when the connection is actually closing }() var err error pingCount := 0 diff --git a/test-relay-connection.js b/test-relay-connection.js new file mode 100755 index 0000000..f81e8b2 --- /dev/null +++ b/test-relay-connection.js @@ -0,0 +1,167 @@ +#!/usr/bin/env node + +// Test script to verify websocket connections are not closed prematurely +// This is a Node.js test script that can be run with: node test-relay-connection.js + +import { NostrWebSocket } from '@nostr-dev-kit/ndk'; + +const RELAY = process.env.RELAY || 'ws://localhost:8080'; +const MAX_CONNECTIONS = 10; +const TEST_DURATION = 30000; // 30 seconds + +let connectionsClosed = 0; +let connectionsOpened = 0; +let messagesReceived = 0; +let errors = 0; + +const stats = { + premature: 0, + normal: 0, + errors: 0, +}; + +class TestConnection { + constructor(id) { + this.id = id; + this.ws = null; + this.closed = false; + this.openTime = null; + this.closeTime = null; + this.lastError = null; + } + + connect() { + return new Promise((resolve, reject) => { + this.ws = new NostrWebSocket(RELAY); + + this.ws.addEventListener('open', () => { + this.openTime = Date.now(); + connectionsOpened++; + console.log(`[Connection ${this.id}] Opened`); + resolve(); + }); + + this.ws.addEventListener('close', (event) => { + this.closeTime = Date.now(); + this.closed = true; + connectionsClosed++; + const duration = this.closeTime - this.openTime; + console.log(`[Connection ${this.id}] Closed: code=${event.code}, reason="${event.reason || ''}", duration=${duration}ms`); + + if (duration < 5000 && event.code !== 1000) { + stats.premature++; + console.log(`[Connection ${this.id}] PREMATURE CLOSE DETECTED: duration=${duration}ms < 5s`); + } else { + stats.normal++; + } + }); + + this.ws.addEventListener('error', (error) => { + this.lastError = error; + stats.errors++; + console.error(`[Connection ${this.id}] Error:`, error); + }); + + this.ws.addEventListener('message', (event) => { + messagesReceived++; + try { + const data = JSON.parse(event.data); + console.log(`[Connection ${this.id}] Message:`, data[0]); + } catch (e) { + console.log(`[Connection ${this.id}] Message (non-JSON):`, event.data); + } + }); + + setTimeout(reject, 5000); // Timeout after 5 seconds if not opened + }); + } + + sendReq() { + if (this.ws && !this.closed) { + this.ws.send(JSON.stringify(['REQ', `test-sub-${this.id}`, { kinds: [1], limit: 10 }])); + console.log(`[Connection ${this.id}] Sent REQ`); + } + } + + close() { + if (this.ws && !this.closed) { + this.ws.close(); + } + } +} + +async function runTest() { + console.log('='.repeat(60)); + console.log('Testing Relay Connection Stability'); + console.log('='.repeat(60)); + console.log(`Relay: ${RELAY}`); + console.log(`Duration: ${TEST_DURATION}ms`); + console.log(`Connections: ${MAX_CONNECTIONS}`); + console.log('='.repeat(60)); + console.log(); + + const connections = []; + + // Open connections + console.log('Opening connections...'); + for (let i = 0; i < MAX_CONNECTIONS; i++) { + const conn = new TestConnection(i); + try { + await conn.connect(); + connections.push(conn); + } catch (error) { + console.error(`Failed to open connection ${i}:`, error); + } + } + + console.log(`Opened ${connections.length} connections`); + console.log(); + + // Send requests from each connection + console.log('Sending REQ messages...'); + for (const conn of connections) { + conn.sendReq(); + } + + // Wait and let connections run + console.log(`Waiting ${TEST_DURATION / 1000}s...`); + await new Promise(resolve => setTimeout(resolve, TEST_DURATION)); + + // Close all connections + console.log('Closing all connections...'); + for (const conn of connections) { + conn.close(); + } + + // Wait for close events + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Print results + console.log(); + console.log('='.repeat(60)); + console.log('Test Results:'); + console.log('='.repeat(60)); + console.log(`Connections Opened: ${connectionsOpened}`); + console.log(`Connections Closed: ${connectionsClosed}`); + console.log(`Messages Received: ${messagesReceived}`); + console.log(); + console.log('Closure Analysis:'); + console.log(`- Premature Closes: ${stats.premature}`); + console.log(`- Normal Closes: ${stats.normal}`); + console.log(`- Errors: ${stats.errors}`); + console.log('='.repeat(60)); + + if (stats.premature > 0) { + console.error('FAILED: Detected premature connection closures!'); + process.exit(1); + } else { + console.log('PASSED: No premature connection closures detected.'); + process.exit(0); + } +} + +runTest().catch(error => { + console.error('Test failed:', error); + process.exit(1); +}); + diff --git a/test-websocket-close.js b/test-websocket-close.js new file mode 100755 index 0000000..3b0aa35 --- /dev/null +++ b/test-websocket-close.js @@ -0,0 +1,57 @@ +import { NostrWebSocket } from '@nostr-dev-kit/ndk'; + +const RELAY = process.env.RELAY || 'ws://localhost:8080'; + +async function testConnectionClosure() { + console.log('Testing websocket connection closure issues...'); + console.log('Connecting to:', RELAY); + + // Create multiple connections to test concurrency + const connections = []; + const results = { connected: 0, closed: 0, errors: 0 }; + + for (let i = 0; i < 5; i++) { + const ws = new NostrWebSocket(RELAY); + + ws.addEventListener('open', () => { + console.log(`Connection ${i} opened`); + results.connected++; + }); + + ws.addEventListener('close', (event) => { + console.log(`Connection ${i} closed:`, event.code, event.reason); + results.closed++; + }); + + ws.addEventListener('error', (error) => { + console.error(`Connection ${i} error:`, error); + results.errors++; + }); + + connections.push(ws); + } + + // Wait a bit then send REQs + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Send some REQ messages + for (const ws of connections) { + ws.send(JSON.stringify(['REQ', 'test-sub', { kinds: [1] }])); + } + + // Wait and observe behavior + await new Promise(resolve => setTimeout(resolve, 5000)); + + console.log('\nTest Results:'); + console.log(`- Connected: ${results.connected}`); + console.log(`- Closed prematurely: ${results.closed}`); + console.log(`- Errors: ${results.errors}`); + + // Close all connections + for (const ws of connections) { + ws.close(); + } +} + +testConnectionClosure().catch(console.error); +