• Listening sockets: SO_REUSEADDR is right on Unix, wrong on Windows; se

    From Rob Swindell@1:103/705 to GitLab issue in main/sbbs on Wed Oct 21 19:30:46 2026
    open https://gitlab.synchro.net/main/sbbs/-/work_items/1293

    ## Summary

    Whether a listening socket gets `SO_REUSEADDR` is currently left to `ctrl/sockopts.ini`, and the right answer differs by platform:

    - **Unix-like:** `SO_REUSEADDR` is the standard, safe choice for a listener. It only lets a server rebind its port while connections from the previous listener are still in `FIN_WAIT_2`/`TIME_WAIT`; it does not let two live listeners share a port. The stock `sockopts.ini` does not enable it (it appears only in a comment).
    - **Windows:** `SO_REUSEADDR` is not needed to rebind over `TIME_WAIT`, and what it does instead is allow a second socket to bind a port that already has an active listener. The safe Windows option for a listener is `SO_EXCLUSIVEADDRUSE`.

    ## Consequences

    **Unix-like, stock config:** after a recycle or restart with connections still closing, `bind()` fails with `EADDRINUSE` until those connections expire (up to `tcp_fin_timeout` plus 60 seconds of `TIME_WAIT`). `retry_bind()` covers part of this, but the defaults (`BindRetryCount=2`, `BindRetryDelay=15`) allow only 30 seconds. Sysops who set `REUSEADDR = 1` or raise `BindRetryCount` don't see it. Related: #402.

    **Windows, wherever `REUSEADDR` is enabled:**

    - A second Synchronet instance starting on the same ports binds successfully instead of failing with "address in use", and the two then split incoming connections unpredictably.
    - Another process under the same account (or an administrator) can bind the port and take connections.
    - The leaked-listener scenario in #1151 relied on exactly this: the new parent could still bind via `SO_REUSEADDR` while an orphaned child held the old listener.

    Because `sockopts.ini` options in the root section apply to every socket, a single `ctrl/` shared between Unix and Windows hosts can't hold a setting that is right for both.

    ## Code locations

    - `xpdev/multisock.c` `xpms_add()`: creates every server listener (Terminal, Mail, FTP, Web, Services, and JS `ListeningSocket`). It sets options only via the `sock_init` callback (`set_socket_options()`, i.e. `sockopts.ini`).
    - `sbbs3/mqtt_broker.cpp` (IPv4 and IPv6 listeners): sets `SO_REUSEADDR` unconditionally, including on Windows.
    - Not affected: `sbbs3/ftpsrvr.cpp` sets `SO_REUSEADDR` on the outgoing active-mode data socket so several transfers can share local port 20. That is correct on both platforms. PASV explicitly clears it.

    ## Proposed fix

    In `xpms_add()` and the MQTT broker listener, before `bind()`:

    - Unix-like: set `SO_REUSEADDR`.
    - Windows: set `SO_EXCLUSIVEADDRUSE`, and never `SO_REUSEADDR`.

    Then either ignore `REUSEADDR` from `sockopts.ini` for listeners on Windows, or document that it is unsafe there.

    -- *Authored by Claude (Claude Code), on behalf of @rswindell*
    --- SBBSecho 3.38-Linux
    * Origin: Vertrauen - [vert/cvs/bbs].synchro.net (1:103/705)
  • From Deucе@1:103/705 to GitLab note in main/sbbs on Wed Oct 21 19:34:44 2026
    https://gitlab.synchro.net/main/sbbs/-/work_items/1293#note_10662

    This item seems to ignore differences between _nix systems and potential interactions with `REUSEPORT`... the differences between_ nix behaviours are complex, a blanket "SO_REUSEADDR is right" statement doesn't pass the sniff test.
    --- SBBSecho 3.38-Linux
    * Origin: Vertrauen - [vert/cvs/bbs].synchro.net (1:103/705)
  • From Deucе@1:103/705 to GitLab note in main/sbbs on Wed Oct 21 19:35:20 2026
    https://gitlab.synchro.net/main/sbbs/-/work_items/1293#note_10662

    This item seems to ignore differences between \*_nix systems and potential interactions with `REUSEPORT`... the differences between \*nix behaviours are complex, a blanket "SO_REUSEADDR is right" statement doesn't pass the sniff test.
    --- SBBSecho 3.38-Linux
    * Origin: Vertrauen - [vert/cvs/bbs].synchro.net (1:103/705)
  • From Deucе@1:103/705 to GitLab note in main/sbbs on Wed Oct 21 19:35:44 2026
    https://gitlab.synchro.net/main/sbbs/-/work_items/1293#note_10662

    This item seems to ignore differences between \*nix systems and potential interactions with `REUSEPORT`... the differences between \*nix behaviours are complex, a blanket "SO_REUSEADDR is right" statement doesn't pass the sniff test.
    --- 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 Fri Oct 23 14:07:24 2026
    https://gitlab.synchro.net/main/sbbs/-/work_items/1293#note_10676

    A live occurrence of the Windows hazard, today (2026-10-23), on the standalone IRCd running as `jsexec -l ircd.js` (32-bit debug build) with `REUSEADDR = 1` in `sockopts.ini`:

    An oper issued `/RESTART`. `terminate_everything()` closed the client and server-link sockets and exited the script; `jsexec -l` re-ran `ircd.js` and `Open_PLines()` bound the ports again. Because the new sockets had `SO_REUSEADDR`, the second `bind()` succeeded on top of the first run's still-open listeners. `netstat` then showed two `LISTENING` sockets on `0.0.0.0:6667`, `[::]:6667`, `0.0.0.0:5050` and `[::]:5050`, all in the same PID. Windows delivered the new connections to the orphaned first-run listeners: a test client's `connect()` completed but no greeting ever arrived, and the linked peer server accumulated connections to us in `CLOSE_WAIT` (an accept backlog nobody reads). The process was otherwise healthy; the main thread was in `js_handle_events()` → `select()`.

    Why the first run's listeners survived at all is a separate defect in `js_socket.cpp`: `do_js_close()` returns early when `p->sock == INVALID_SOCKET`, and a `ListeningSocket` always has that value (its real sockets live in `p->set`). So neither `close()` nor the GC finalizer ever closes a `ListeningSocket`'s sockets; `xpms_destroy()` is only called on constructor failure paths. The IRCd has used `ListeningSocket` under JSexec since e1b902b718 (2019), so every `/RESTART` under `jsexec -l` has leaked a set of listeners. On Unix the rebind would fail with `EADDRINUSE` and the restarted daemon would have no listener; on Windows with `REUSEADDR` it silently takes the port alongside the dead one. Filed separately as the `ListeningSocket` close bug; `SO_EXCLUSIVEADDRUSE` on Windows would have turned this into an immediate, visible bind failure instead.

    -- *Authored by Claude (Claude Code), on behalf of @rswindell*
    --- 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 Fri Oct 23 14:08:08 2026
    https://gitlab.synchro.net/main/sbbs/-/work_items/1293#note_10676

    A live occurrence of the Windows hazard, today (2026-10-23), on the standalone IRCd running as `jsexec -l ircd.js` (32-bit debug build) with `REUSEADDR = 1` in `sockopts.ini`:

    An oper issued `/RESTART`. `terminate_everything()` closed the client and server-link sockets and exited the script; `jsexec -l` re-ran `ircd.js` and `Open_PLines()` bound the ports again. Because the new sockets had `SO_REUSEADDR`, the second `bind()` succeeded on top of the first run's still-open listeners. `netstat` then showed two `LISTENING` sockets on `0.0.0.0:6667`, `[::]:6667`, `0.0.0.0:5050` and `[::]:5050`, all in the same PID. Windows delivered the new connections to the orphaned first-run listeners: a test client's `connect()` completed but no greeting ever arrived, and the linked peer server accumulated connections to us in `CLOSE_WAIT` (an accept backlog nobody reads). The process was otherwise healthy; the main thread was in `js_handle_events()` ? `select()`.

    Why the first run's listeners survived at all is a separate defect in `js_socket.cpp`: `do_js_close()` returns early when `p->sock == INVALID_SOCKET`, and a `ListeningSocket` always has that value (its real sockets live in `p->set`). So neither `close()` nor the GC finalizer ever closes a `ListeningSocket`'s sockets; `xpms_destroy()` is only called on constructor failure paths. The IRCd has used `ListeningSocket` under JSexec since e1b902b718 (2019), so every `/RESTART` under `jsexec -l` has leaked a set of listeners. On Unix the rebind would fail with `EADDRINUSE` and the restarted daemon would have no listener; on Windows with `REUSEADDR` it silently takes the port alongside the dead one. Filed separately as #1296; `SO_EXCLUSIVEADDRUSE` on Windows would have turned this into an immediate, visible bind failure instead.

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