Skip to content

fix(hosts): detach connection-close listener when host disconnects - #82

Open
krazyjakee wants to merge 1 commit into
foxssake:mainfrom
krazyjakee:fix/register-host-listener-leak
Open

fix(hosts): detach connection-close listener when host disconnects#82
krazyjakee wants to merge 1 commit into
foxssake:mainfrom
krazyjakee:fix/register-host-listener-leak

Conversation

@krazyjakee

@krazyjakee krazyjakee commented Jul 21, 2026

Copy link
Copy Markdown

What

handleRegisterHost adds a listener to the process-global NorayEvents bus on every register-host command and never removes it. This makes the listener self-removing so it is detached once its socket has closed.

Fixes #81.

Why

In src/hosts/host.commands.ts, each register-host does:

NorayEvents.on("connection-close", (closed) => {
  if (closed !== socket) return;
  ...
});

NorayEvents (src/events.ts) is a module-level singleton whose EventBus stores handlers in a plain array that is only ever pushed to — there was no matching .off(...) anywhere. Since clients also issue register-host, every connection (host or client) permanently appends a closure that retains socket and host. Over time:

  • Memory grows without bound (leaked closures + the sockets/hosts they capture).
  • emit("connection-close", ...) becomes O(number of connections ever made), since each disconnect walks the entire accumulated array.
  • Eventually the event loop is starved enough that the TCP socket still accepts connections while the command handler stops replying — the "port bound but process hung" symptom that requires a restart. This is the same class of unresponsiveness the dbg/unresponsive-tcp / fix/unresponsive-tcp branches appear to be investigating; those branches add diagnostics but don't detach this listener.

Reproduction

Against ghcr.io/foxssake/noray:main (v1.5.4), 15,000 connect → register-host → disconnect cycles:

memory after 15k cycles register-host reply latency
main ~29 → ~65 MiB (linear, retained) drifting upward
this PR ~31 → ~43 → plateaus ~37 MiB (GC reclaims) flat

Driver:

import socket
for _ in range(15000):
    s = socket.create_connection(("127.0.0.1", 8890), timeout=2)
    s.sendall(b"register-host\n")
    s.recv(256)
    s.close()

noray_active_hosts also stops drifting above the real host count with the fix.

Change

Extract the inline listener into a named onConnectionClose and call NorayEvents.off("connection-close", onConnectionClose) once it has handled its own socket's close. No behavioural change for a live host — only the cleanup that was missing.

handleRegisterHost registered a listener on the process-global NorayEvents
bus for every register-host command and never removed it. The listener array
(and the socket/host each closure retained) grew without bound, and every
connection-close emit became O(number of connections ever made). Over long
uptimes this leaked memory and starved the reactor until it stopped answering
commands while the TCP socket still accepted connections, requiring a restart.

Make the listener self-removing so it is detached once its socket has closed.
Memory then plateaus and per-disconnect cost stays constant.

Fixes foxssake#81

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@krazyjakee
krazyjakee force-pushed the fix/register-host-listener-leak branch from e426280 to 6635365 Compare July 21, 2026 23:02
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.

register-host leaks a global connection-close listener per connection (host stops answering after long uptime)

1 participant