open
https://gitlab.synchro.net/main/sbbs/-/work_items/1333
## Summary
When a server shuts down or recycles, a child thread that is blocked somewhere other than its client socket (a script stuck in a native call, a CGI/FastCGI pipe, a mutex, file I/O on a stalled network share) holds up the shutdown. How each server handles that differs:
| Server | Wait for child threads | After giving up |
|---|---|---|
| Web | bounded: `TIMEOUT_THREAD_WAIT` (60 s), re-kicking client sockets every 5 s | frees shared state while the stuck threads still run |
| FTP | **unbounded** | n/a |
| Mail | **unbounded** | n/a |
| Terminal | node threads: 60 s; events thread: **unbounded** (its timeout is `#if 0`'d because the thread "can/will segfault if it continues to run and dereference sbbs->cfg") | frees `events`/`sbbs` only if those threads have stopped |
| Services | dynamic clients and static services: **unbounded** | n/a |
Code references are as of master ab31f9f1c0 (flux-3-skip, 2026-11-10).
## Unbounded waits (FTP, Mail)
`ftpsrvr.cpp` `cleanup()` (`:5355-5361`) and `mailsrvr.cpp` `cleanup()` (`:6359-6364`) loop on `thread_count > 1` with `mswait(100)` and no time limit or re-kick. One stuck thread holds the server, and under systemd the whole `sbbs` process, until the stop timeout kills it (SIGKILL, no orderly cleanup). Services (`services.cpp:2681`, `:2703`) and the terminal server's events-thread wait (`main.cpp:6479-6491`) behave the same way.
## Freeing shared state after the timeout (Web)
The web server bounded its wait in a45592ec5b (defend-45-tech, 2026-09-30). On timeout, `cleanup()` (`websrvr.cpp:7382`) logs `!TIMEOUT waiting for N child thread(s) to terminate` and continues straight into `free_cfg(&scfg)`, `listFree(&log_list)`, the `iniFree*` calls for `mime_types`, `cgi_handlers`, `xjs_handlers`, `alias_list`, `cgi_env`, the semfile lists, and `listFree(¤t_connections)`, while those threads still run and may touch any of it when they wake.
- On **shutdown** the process exits shortly afterwards, so the window is small, though not zero.
- On **recycle** the process keeps running: `cleanup(0)` (`:8215`) is followed by a re-read of the configuration, and a stuck session that wakes later dereferences freed or reloaded `scfg`, MIME/handler lists, and so on. That's a use-after-free.
This was observed in production on 2026-11-10: a web server shutdown ended with `!TIMEOUT waiting for 9 child thread(s) to terminate` and `!!!! Terminating with 8 child thread(s) still running`. The periodic `shutdown_sessions()` kicks logged nothing, so none of the 9 threads still had a registered client connection, i.e. they were blocked off-socket.
## Suggested direction
- Give FTP and Mail the same bounded, re-kicking wait the web server has.
- On a timeout, don't free state the stuck threads can still reach. Either leak it deliberately (acceptable on shutdown, since the process is exiting) or skip the recycle and fall back to a full stop/restart when threads are left behind, so a stray thread never sees a reloaded configuration.
- Apply the same rule to the web server's existing timeout path.
- Log what each remaining thread is (session/socket, client IP, current request or script) when the wait times out, so the blocking call can be identified from the log alone.
*Authored by Claude (Claude Code), on behalf of @rswindell*
--- SBBSecho 3.38-Linux
* Origin: Vertrauen - [vert/cvs/bbs].synchro.net (1:103/705)