• JS ListeningSocket can never be closed: do_js_close() returns before c

    From Rob Swindell@1:103/705 to GitLab issue in main/sbbs on Fri Oct 23 14:07:52 2026
    open https://gitlab.synchro.net/main/sbbs/-/work_items/1296

    ## Summary

    A JS `ListeningSocket` can never be closed. Neither `.close()` nor garbage collection releases the underlying sockets, so every script that creates one and is then re-run in the same process (`jsexec -l`, a Services recycle, the IRCd's `/RESTART`) leaks a full set of bound, listening sockets.

    ## Cause

    `js_listening_socket_constructor()` (`src/sbbs3/js_socket.cpp`, around line 3615) stores the real sockets in `p->set` (an `xpms_set`) and sets `p->sock = INVALID_SOCKET`, `p->external = true`.

    `do_js_close()` (same file, line 223) removes the event handlers for each `p->set` socket, then:

    ```c
    if (p->sock == INVALID_SOCKET) {
    p->is_connected = false;
    return;
    }
    ```

    For a `ListeningSocket` that condition is always true, so the function returns before anything is closed. `js_finalize_socket()` calls `do_js_close()` and then `if (!p->external) free(p->set);`, which is also skipped because `p->external` is true. The only `xpms_destroy()` calls in the file are on constructor failure paths. The listening sockets stay open for the life of the process.

    ## Observed today (2026-10-23)

    The IRCd running standalone as `jsexec -l ircd.js` on Windows, `REUSEADDR = 1` in `sockopts.ini`. After an oper's `/RESTART`, `terminate_everything()` closed the client and server-link sockets and exited; `jsexec -l` re-ran `ircd.js`, and `Open_PLines()` bound the ports again. `netstat` showed two `LISTENING` sockets per address on ports 6667 and 5050 in the one PID. Windows delivered new connections to the first run's orphaned listeners: clients connected but never received a greeting, and the linked peer server's connections piled up in `CLOSE_WAIT`. The event loop was running normally the whole time. See #1293 for the `SO_REUSEADDR` side of this; on Unix the second `bind()` fails with `EADDRINUSE` instead and the restarted daemon has no listener.

    The IRCd has used `ListeningSocket` under JSexec since e1b902b718 (2019). Before that it used a plain `Socket` with `bind()`/`listen()`, whose `p->sock` is valid, so GC closed it and `/RESTART` under `jsexec -l` worked.

    ## Proposed fix

    In `do_js_close()`, before the `INVALID_SOCKET` early return:

    ```c
    if (p->set) {
    xpms_destroy(p->set, sock_close_cb, NULL);
    p->set = NULL;
    }
    ```

    and drop the now-redundant `free(p->set)` from `js_finalize_socket()` (or keep it guarded; `xpms_destroy()` frees the set). `js_accept()` and the event-registration code already treat `p->set == NULL` as "not a listener", so a closed `ListeningSocket` then behaves like a closed `Socket`.

    Secondary, in `exec/load/ircd/core.js`: `Open_PLines()` discards the sockets it creates. Keep them and close them in `terminate_everything()` so a `/RESTART` releases the ports explicitly rather than depending on GC timing.

    -- *Authored by Claude (Claude Code), on behalf of @rswindell*
    --- SBBSecho 3.38-Linux
    * Origin: Vertrauen - [vert/cvs/bbs].synchro.net (1:103/705)
  • From Bob Roberts@1:103/705 to GitLab note in main/sbbs on Thu Nov 5 16:52:37 2026
    https://gitlab.synchro.net/main/sbbs/-/work_items/1296#note_10800

    ### Confirmed on Linux: master `55431cba50` (IRCd 2.11)

    **Setup:** IRC as a `STATIC | LOOP` service under sbbs (not `jsexec -l`), listeners on 6667 and 6697 (TLS).

    **Trigger:** a services recycle (`touch ctrl/recycle.services`). `ircd.js` restarts in the same process, and the new instance can't bind 6697:

    ```
    srvc 0073 !ERROR 98 binding IRCd socket to port 6697: Address already in use srvc -001 IRC !Error Error: Unable to add host to socket set creating listening socket on port 6697
    ```

    **Result:** the orphaned listener from the previous run stays in `LISTEN` until the process exits. It accepts TCP into its backlog but never completes a TLS handshake, so 6697 is dead until a full `systemctl restart sbbs`.

    #### Minimal repro (jsexec, loopback)

    ```js
    load("sockdefs.js");
    var a = new ListeningSocket(["127.0.0.1"], 47699, "Test", {retry_count: 0}); a.close();
    var b = new ListeningSocket(["127.0.0.1"], 47699, "Test", {retry_count: 0}); // throws: errno 98
    ```

    | Case | Rebind same port |
    |---|---|
    | `Socket` + `bind()`/`listen()`, dropped and GC'd | ✅ OK |
    | `ListeningSocket`, explicit `close()` | ❌ errno 98 |
    | `ListeningSocket`, dropped and GC'd | ❌ errno 98 |
    --- SBBSecho 3.38-Linux
    * Origin: Vertrauen - [vert/cvs/bbs].synchro.net (1:103/705)
  • From Rob Swindell@1:103/705 to GitLab note in main/sbbs on Thu Nov 5 18:23:30 2026
    https://gitlab.synchro.net/main/sbbs/-/work_items/1296#note_10804

    Fixed in 13d94e6451 (fires-13-willow, 2026-11-05).

    ### The fix differs from the one proposed above

    The proposal destroys any socket set in `do_js_close()`. That would also destroy the set behind a static service's `server.socket`: `services.cpp` passes its own listening set to `js_CreateSocketObjectFromSet()`, and destroys that set itself when the service ends. Both kinds of object are marked `external`, so nothing told them apart, and Services would have freed its set a second time.

    The commit adds a `set_owned` flag to `js_socket_private_t`, set only by the `ListeningSocket` constructor. `do_js_close()` destroys the set only when it is owned, after removing its event handlers, so `close()` and finalization both release the listeners and `server.socket` is left alone. The `free(p->set)` in `js_finalize_socket()` is gone; it could never run for a set.

    ### Verified on Linux

    - New regression test `exec/tests/socket/listening-close.js`: the port can be bound again after `close()`, after a second `close()`, after garbage collection, and after `close()` with a read handler installed. It failed before the change and passes after.
    - A script that creates a `ListeningSocket` with a read handler, run under `jsexec -l`: before, the first run bound and every later run failed with errno 98; after, every run binds.
    - The `server.socket` path was checked by reading the code, not by running a static service.
    - Not yet run on Windows.

    ### IRCd

    Keeping the listeners in `Open_PLines()` and closing them in `terminate_everything()` is no longer needed for a restart to work, since tearing down the script's context now releases them. It would still make the release explicit.

    ### Related

    814800d565 (select-19-shower, 2026-11-05) fixes a separate fault found in the same code: when `js_DefineSocketOptionsArray()` failed, the `Socket`, `ConnectedSocket` and `ListeningSocket` constructors freed their private data while the new object still pointed to it, and the finalizer then used and freed it again.

    — *Authored by Claude (Claude Code), on behalf of @rswindell*
    --- SBBSecho 3.38-Linux
    * Origin: Vertrauen - [vert/cvs/bbs].synchro.net (1:103/705)