-
Notifications
You must be signed in to change notification settings - Fork 9
fix: don't reconnect a websocket after close()
#120
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
EnriqueL8
merged 5 commits into
hyperledger-firefly:main
from
kaleido-io:websocket-reconnect
Sep 14, 2026
+184
−18
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
3b86ebe
regression test to prove issue
annamcallister 72ee18a
prevent unexpected reconnects
annamcallister 49548a5
rename variables
annamcallister 4eedc10
add test workflow
annamcallister 695e861
move to node 24 on all workflows
annamcallister File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| name: Tests | ||
|
|
||
| on: | ||
| pull_request: | ||
| push: | ||
| branches: [main] | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| jobs: | ||
| test: | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@v5 | ||
| - uses: actions/setup-node@v5 | ||
| with: | ||
| node-version: '24.x' | ||
| cache: npm | ||
| # npm ci runs prepare, which builds the package, so a broken build fails here | ||
| - run: npm ci | ||
| - run: npm test |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,137 @@ | ||
| import * as assert from 'assert'; | ||
| import { AddressInfo } from 'net'; | ||
| import { WebSocketServer } from 'ws'; | ||
| import { FireFlyWebSocket } from '../lib/websocket'; | ||
|
|
||
| // These are private, but there is no other way to simulate a handler that was still in flight | ||
| // when the socket was closed, or to guarantee a socket is down at the end of a test. | ||
| type SocketInternals = { | ||
| options: { reconnectDelay: number }; | ||
| reconnectTimer?: NodeJS.Timeout; | ||
| reconnect(msg: string): void; | ||
| }; | ||
|
|
||
| const internals = (socket: FireFlyWebSocket) => socket as unknown as SocketInternals; | ||
|
|
||
| const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); | ||
|
|
||
| describe('websocket', () => { | ||
| const reconnectDelay = 100; | ||
|
|
||
| let server: WebSocketServer; | ||
| let sockets: FireFlyWebSocket[]; | ||
| let upgradeAttempts: number; | ||
| let acceptConnections: boolean; | ||
| let previousLogLevel: string | undefined; | ||
|
|
||
| beforeEach(async () => { | ||
| // Logger reads the level when it is constructed, so this has to happen before any socket | ||
| previousLogLevel = process.env.FF_SDK_LOG_LEVEL; | ||
| process.env.FF_SDK_LOG_LEVEL = 'NONE'; | ||
|
|
||
| sockets = []; | ||
| upgradeAttempts = 0; | ||
| acceptConnections = false; | ||
| server = new WebSocketServer({ | ||
| port: 0, | ||
| verifyClient: (info, cb) => { | ||
| upgradeAttempts++; | ||
| // Rejecting sends a 401, so the attempt reaches the unexpected-response handler | ||
| // that the SDK reconnects from | ||
| acceptConnections ? cb(true) : cb(false, 401, 'Unauthorized', {}); | ||
| }, | ||
| }); | ||
| await new Promise<void>((resolve) => server.on('listening', resolve)); | ||
| }); | ||
|
|
||
| afterEach(async () => { | ||
| // close() should stop a socket reconnecting on its own. Disabling reconnection here | ||
| // too means that if it ever stops doing so, the test fails its assertion instead of | ||
| // leaving a socket retrying against a closed port, which hangs the whole run. | ||
| for (const socket of sockets) { | ||
| const socketInternals = internals(socket); | ||
| socketInternals.options.reconnectDelay = -1; | ||
| if (socketInternals.reconnectTimer) { | ||
| clearTimeout(socketInternals.reconnectTimer); | ||
| delete socketInternals.reconnectTimer; | ||
| } | ||
| await socket.close(); | ||
| } | ||
| process.env.FF_SDK_LOG_LEVEL = previousLogLevel; | ||
| await new Promise<void>((resolve, reject) => | ||
| server.close((err) => (err ? reject(err) : resolve())), | ||
| ); | ||
| }); | ||
|
|
||
| function newSocket() { | ||
| const { port } = server.address() as AddressInfo; | ||
| const socket = new FireFlyWebSocket( | ||
| { | ||
| host: `ws://127.0.0.1:${port}`, | ||
| namespace: 'ns', | ||
| subscriptions: [], | ||
| reconnectDelay, | ||
| heartbeatInterval: 30000, | ||
| ephemeral: { namespace: 'ns', filter: { events: 'x' } }, | ||
| }, | ||
| () => {}, | ||
| ); | ||
| sockets.push(socket); | ||
| return socket; | ||
| } | ||
|
|
||
| async function waitForAttempts(count: number) { | ||
| while (upgradeAttempts < count) { | ||
| await delay(5); | ||
| } | ||
| } | ||
|
|
||
| it('reconnects when the peer drops the connection', async () => { | ||
| acceptConnections = true; | ||
| let dropped = false; | ||
| server.on('connection', (peer) => { | ||
| if (!dropped) { | ||
| dropped = true; | ||
| peer.terminate(); | ||
| } | ||
| }); | ||
|
|
||
| newSocket(); | ||
|
|
||
| // A second attempt is the socket coming back after the drop | ||
| await waitForAttempts(2); | ||
| }); | ||
|
|
||
| it('does not reconnect after close() when a reconnect is already pending', async () => { | ||
| const socket = newSocket(); | ||
|
|
||
| // A second attempt means the first rejection scheduled a reconnect. Closing half a | ||
| // backoff later puts us in the state an application is in when it closes a socket that | ||
| // has been failing - the connection is down and the SDK is waiting to retry it. | ||
| await waitForAttempts(2); | ||
| await delay(reconnectDelay / 2); | ||
| await socket.close(); | ||
|
|
||
| // The socket is closed, so the server should see no further connection attempts | ||
| const attemptsAtClose = upgradeAttempts; | ||
| await delay(reconnectDelay * 5); | ||
| assert.strictEqual( | ||
| upgradeAttempts - attemptsAtClose, | ||
| 0, | ||
| 'socket kept reconnecting after close()', | ||
| ); | ||
| }); | ||
|
|
||
| it('does not reconnect when an in-flight handler reconnects after close()', async () => { | ||
| const socket = newSocket(); | ||
| await waitForAttempts(1); | ||
| await socket.close(); | ||
|
|
||
| // unexpected-response reconnects from a deferred stream flush, so a rejection that was | ||
| // still being drained when we closed lands after the close has completed | ||
| const attemptsAtClose = upgradeAttempts; | ||
| internals(socket).reconnect('FireFly connect error [401]'); | ||
| await delay(reconnectDelay * 5); | ||
| assert.strictEqual(upgradeAttempts - attemptsAtClose, 0, 'socket reconnected after close()'); | ||
| }); | ||
| }); |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I assume the node-version 24 includes 11.5.1?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yes- 24.x should resolve to node
24.21.0/ npm11.19.0right now, and obviously only ever go up