win_server is a single-threaded Windows TCP server that turns each accepted
client connection into a child process session. The accepted socket is
redirected to the child process as stdin, stdout, and stderr, so the
remote client interacts with the child process as if it were attached to a
console-like stream.
The implementation lives in win_server.c and is intentionally
small: it relies on Winsock events, WaitForMultipleObjects, and Windows job
objects instead of worker threads or an I/O completion framework.
In short, the process model is:
- Start a listening TCP socket.
- Accept a client connection.
- Spawn one child process for that connection.
- Monitor both the child process handle and the client disconnect event.
- Tear down the process session immediately if the client goes away.
- Keep orphaned descendants alive only when the original child exits on its own and background descendants still remain.
The current design optimizes for the following goals:
- Keep the implementation easy to inspect and debug.
- Avoid creating extra threads for per-connection management.
- Ensure child processes do not outlive the server accidentally.
- Detect client disconnects promptly and reclaim resources quickly.
- Preserve background descendants only in the narrow case where the child exits naturally and leaves additional processes behind.
The current implementation deliberately does not try to provide:
- High connection fan-out beyond the
WaitForMultipleObjectshandle limit. - Asynchronous overlapped I/O for client traffic.
- A shell-escaping layer for rebuilding Windows command lines.
- Authentication, encryption, or access control.
- A protocol above the raw redirected byte stream.
The server is built around one main event loop. It waits on four classes of objects:
- A shutdown event set by the console control handler.
- A listening socket event used for
FD_ACCEPT. - One process handle per active child.
- One socket event per active child used for
FD_CLOSE.
This leads directly to the maximum concurrency limit:
MAXIMUM_WAIT_OBJECTSis 64 on Windows.- Two entries are reserved for shutdown and listen events.
- Each child consumes two wait slots: one process handle and one socket event.
- Therefore
MAX_CHILD_PROCESSES = (64 - 2) / 2 = 31.
The design favors predictability over scale. Every active session is visible in one array, every wait target is rebuilt on each loop iteration, and all state transitions happen in the main thread.
ChildProcess is the primary in-memory session record for active clients.
process_handle: handle used to wait for child exit.session_job_handle: per-session job object used for cleanup.client_socket: server-side socket handle retained after spawn.socket_event: Winsock event that signalsFD_CLOSE.process_id: child PID for logs and diagnostics.client_disconnect_detected: remembers that the remote side went away.force_termination_requested: remembers that the server intentionally killed the session.
DetachedSession tracks a job object whose original child has already exited
but which still contains descendant processes.
job_handle: handle kept open so descendants remain alive.child_process_id: PID of the original child, used for logging context.
This is a dynamic array that stores detached sessions until they become empty or the server shuts down.
Server startup follows this order:
- Parse arguments and optional
-pport. - Build the target command line string from the remaining arguments.
- Initialize Winsock with
WSAStartup. - Create a listening socket with
WSASocketA. - Mark the listening socket as non-inheritable.
- Enable
SO_EXCLUSIVEADDRUSEso a second process cannot bind the same port. - Bind to
0.0.0.0:<port>. - Start listening.
- Create the manual-reset shutdown event.
- Register
SetConsoleCtrlHandler. - Create a Winsock event for the listening socket and subscribe to
FD_ACCEPT.
On Windows, SO_REUSEADDR does not behave like the Unix-style "quick restart"
flag many developers expect. This server instead uses SO_EXCLUSIVEADDRUSE so
that accidental double-starts fail reliably.
The event loop in main() is the center of the design.
At the beginning of each iteration, the server:
- Calls
prune_detached_sessions()to close job handles whose descendant count has already dropped to zero. - Rebuilds the wait handle array from current active state.
- Rebuilds a
socket_event_map[]array so socket-event indexes can be mapped back tochildren[]entries. - Calls
WaitForMultipleObjects().
The result is then dispatched into one of four handlers:
- Shutdown event: break the loop and begin global cleanup.
- Listen event: drain all pending accepts via
accept_pending_connections(). - Child process handle: reap one child via
reap_child_process(). - Client socket event: decode Winsock events and trigger disconnect handling.
children[] is compacted when a child exits. Rebuilding the arrays on each
iteration keeps the indexing logic simple and avoids stale slots.
The accepted socket is handed to the child for I/O, but the parent still needs
its own view of connection liveness. The per-child FD_CLOSE event gives the
server a prompt signal when the remote peer disconnects, even if the child is
blocked on I/O or not handling errors itself.
The listen socket is associated with a Winsock event for FD_ACCEPT. When that
event is signaled, the server calls accept_pending_connections() and loops on
WSAAccept() until it reaches WSAEWOULDBLOCK.
This "drain until empty" pattern prevents the server from leaving queued connections unaccepted after receiving only one readiness notification.
For each accepted socket, the server:
- Logs the client address if possible.
- Rejects the connection if
child_counthas reached 31. - Resets the accepted socket into blocking mode for child-side stdio use.
- Spawns the child process.
- Stores the session in
children[].
When the server is full, it still tries to be polite:
- Restore the accepted socket to blocking mode.
- Send a short rejection message.
- Close the socket.
This avoids silent drops and keeps the overload behavior explicit.
spawn_child() contains the most important lifecycle logic in the program.
The sequence is:
- Duplicate the command line into a writable buffer because
CreateProcessAmay modify it in place. - Mark the accepted socket handle as inheritable.
- Create a per-session job object with
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE. - Prepare
STARTUPINFOAsostdin,stdout, andstderrall point to the accepted socket handle. - Create the process in the suspended state with
CREATE_SUSPENDED. - Clear the inherit flag on the parent's socket handle.
- Assign the child process to the job object.
- Resume the child thread.
- Create a Winsock event for this client socket.
- Subscribe the parent-side socket handle to
FD_CLOSE. - Publish the populated
ChildProcessrecord.
Creating the child suspended closes an important race. It allows the server to assign the child to the session job object before user code starts running, so the process cannot briefly escape job membership during startup.
The child inherits its own standard-handle reference to the socket. The parent
retains another reference so it can detect FD_CLOSE, shut the socket down,
and force session cleanup when the client disappears.
The listening socket is switched into event-driven mode with WSAEventSelect()
for FD_ACCEPT, which implies non-blocking semantics. Accepted sockets inherit
state that is not suitable for child stdio usage, so the server explicitly
restores blocking mode in make_socket_blocking_for_child().
That helper performs two actions:
- Clears any event association with
WSAEventSelect(client_sock, NULL, 0). - Resets
FIONBIOto blocking mode withioctlsocket().
Later, once the child is running, the parent associates the same socket handle
with a dedicated event again, but only for FD_CLOSE monitoring.
Client disconnects are handled by note_client_disconnect().
When a child's socket event reports FD_CLOSE, the server:
- Marks
client_disconnect_detected = TRUE. - Logs that the client disconnected.
- Calls
shutdown(..., SD_BOTH)when possible. - Closes the parent-side socket and socket event.
- Forces termination of the child session.
The actual kill operation is performed by force_terminate_child():
- First choice:
TerminateJobObject(session_job_handle, ERROR_BROKEN_PIPE). - Fallback:
TerminateProcess(process_handle, ERROR_BROKEN_PIPE).
Using the job object first makes sure the entire process tree is terminated instead of only the original child.
When a process handle becomes signaled, reap_child_process() performs the
post-exit workflow:
- Log the exit status.
- Close the session's parent-side socket and socket event.
- Decide what to do with the session job object.
- Close the process handle.
- Compact the
children[]array.
log_child_exit_status() classifies exits into several categories:
- Normal exit.
- Exit after client disconnect.
- Forced termination after disconnect.
- Console-control termination (
0xC000013A). - Known crash signatures such as access violations or stack overflows.
- Generic abnormal NTSTATUS values.
- Other non-zero exit codes.
This is mainly an observability feature, but it also encodes the server's mental model of "expected" versus "suspicious" child termination.
One subtle feature of the design is the detached-session mechanism.
If the original child exits on its own, the server does not immediately close
the session job handle. Instead, release_child_session_job() queries the job
object's active-process count.
The outcomes are:
force_termination_requested == TRUE: close the job immediately, which kills anything still in it.- Active process count is zero: close the job immediately.
- Active process count is non-zero: move the job handle into
DetachedSessionListso descendants remain alive.
This supports scenarios where the launched child intentionally starts a helper or worker process and then exits. Those descendants are still bounded by the job object and will be cleaned up when:
- the descendants themselves eventually exit, or
win_server.exeshuts down and closes all retained job handles.
The implementation draws a strict distinction between:
- child exited on its own: background descendants may be intentional;
- client disconnected or server forced teardown: the whole session should die immediately.
That policy reduces the chance of leaving behind stray background processes after broken client sessions.
Shutdown is triggered by the console control handler setting
g_shutdown_event. The main loop then exits and enters the shared cleanup
block.
Cleanup order is:
- Close each active child's socket event and client socket.
- Close each active child's session job handle.
- Close each active child's process handle.
- Close all detached session job handles.
- Close the listen event.
- Close the listen socket.
- Close the shutdown event.
- Unregister the console control handler.
- Call
WSACleanup(). - Free the command line buffer.
Because session jobs use JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, closing those
handles is enough to kill remaining in-job descendants during shutdown.
The implementation uses a pragmatic error model:
- Fatal startup failures abort the program with a non-zero exit code.
- Per-connection failures are logged and isolated to the affected client.
- Cleanup paths prefer best-effort behavior over attempting deep recovery.
- Warnings are emitted when the preferred cleanup path fails and the server falls back to a weaker mechanism.
This is visible in several places:
- Failing to create or assign a job object does not block child startup; the server falls back to direct process termination.
- Failing to classify an address string still allows the connection.
- Failing to prune a detached session leaves it retained until a later pass.
Logging is intentionally simple:
- Timestamped local time.
INFO,WARN, andERRORlevels.ERRORgoes tostderr; other levels go tostdout.
The logger is synchronous and line-oriented, which matches the single-threaded design and keeps log ordering easy to follow.
The implementation is intentionally conservative, but a few tradeoffs are worth calling out explicitly.
The 31-session cap is a direct result of using one wait call over process and socket event handles. Moving beyond that would require a different concurrency model such as thread pools, multiple wait sets, or I/O completion ports.
build_command_line() simply joins arguments with spaces. It does not perform
full Windows quoting and escaping. In practice, callers should provide
arguments in a form that CreateProcessA will parse correctly.
The server is excellent at lifecycle control, but it is not a shell protocol. It treats the network connection as a raw byte stream and delegates all higher-level semantics to the child process.
The implementation intentionally uses Windows-specific APIs such as Winsock events, console control handlers, and job objects. Portability is not a goal.
For a small, inspectable Windows utility, this design hits a useful balance:
- small enough to understand in one source file,
- strict enough to avoid orphan process leaks,
- responsive enough to react quickly to disconnects,
- flexible enough to let intentional descendants survive until server shutdown.
That makes it a good fit for remote command execution, test harnesses, and other controlled environments where operational predictability matters more than very high connection counts.