Skip to content

Conversation

@samholmes
Copy link
Contributor

@samholmes samholmes commented Dec 7, 2025

CHANGELOG

Does this branch warrant an entry to the CHANGELOG?

  • Yes
  • No

Dependencies

none

Description

none

…ction close

The handleClose() function rejected pending calls but never cleared the
remoteCalls map, and the remoteSubscriptions map was never cleared at all.

This caused memory to accumulate over the lifetime of each codec instance:
- remoteCalls retained rejected promise handlers
- remoteSubscriptions grew unbounded with each subscription request

Now both maps are explicitly cleared after rejecting pending calls,
allowing the garbage collector to reclaim the memory.
The unconfirmedTxWatchlist Map tracks unconfirmed transactions per address,
but entries were never removed when:
- An address was explicitly unsubscribed via removeAddressConnection()
- A WebSocket connection closed unexpectedly

This caused orphaned transaction IDs to accumulate indefinitely, as
transactions that are dropped from the mempool (never confirmed) would
remain in the watchlist forever.

Now the watchlist is cleaned up in both scenarios:
- When removeAddressConnection() is called during normal unsubscribe
- When a connection closes and addresses are cleaned up in the close handler
Plugins had resources that were never cleaned up: periodic timers, block
watchers, and WebSocket connections. This caused resource leaks during
server shutdown or plugin recreation.

Changes:
- Added optional 'destroy' method to AddressPlugin interface
- Implemented destroy() in blockbook plugin:
  - Stops the pingTask periodic timer
  - Closes all WebSocket connections
  - Clears internal maps
  - Added 'destroyed' flag to prevent reconnection after destroy
- Added destroy() to AddressHub that calls destroy() on all plugins
- Added graceful shutdown in server workers (SIGTERM/SIGINT handlers)
  that closes connections and calls hub.destroy()
The client.watchBlocks() call returns an unwatch function that was being
ignored. This meant the block watcher would run indefinitely with no way
to stop it, even if the plugin was recreated or the server shut down.

If multiple plugin instances were created (e.g., during hot reload or
testing), multiple watchers would be active simultaneously, each consuming
resources and potentially causing duplicate event emissions.

Now the unwatch function is stored and called in the new destroy() method,
which also clears the subscribedAddresses map.
The transportUrlMap was using a regular Map to store transport instance to
URL mappings. Since Map holds strong references to its keys, transport
instances would never be garbage collected even after they were no longer
in use by viem's fallback transport mechanism.

By switching to WeakMap, transport instances that are no longer referenced
elsewhere can be garbage collected, preventing gradual memory growth over
the lifetime of the plugin.
The fake plugin's subscribe() method created a setTimeout that could not
be cancelled. If unsubscribe() was called before the timeout fired:
- The timeout would still execute and emit an update for an address
  that was no longer subscribed
- This could cause unexpected behavior in tests

Now the plugin:
- Tracks pending timeouts in a Map keyed by address
- Clears the timeout when unsubscribe() is called
- Implements destroy() to clear all pending timeouts

This makes the fake plugin behave more predictably in test scenarios
and prevents orphaned timer callbacks.
The CLI tool was not calling codec.handleClose() when the WebSocket
connection closed. This left pending RPC promises unresolved, which
could cause the process to hang or produce confusing errors.

Now handleClose() is called to properly reject any pending method calls
before closing the readline interface and WebSocket.
When asResult() threw an exception, pendingCall.reject() was called but
then pendingCall.resolve(cleanResult) was also called with undefined.

This could cause unpredictable behavior since the promise would be both
rejected and resolved. Now resolve() only executes if asResult() succeeds.
@samholmes samholmes merged commit 974bfca into main Dec 10, 2025
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants