This document helps coding agents understand the Apache module for socket handoff.
This Apache module allows PHP (or any handler) to authenticate a request and run any business logic needed, then hand off the client connection to an external daemon for streaming responses. The Apache worker is freed immediately, allowing efficient handling of long-running streams like LLM responses.
The core mechanism is Unix domain socket fd passing via SCM_RIGHTS:
- Apache sends the client's TCP socket fd to a daemon over a Unix socket
- The daemon receives the fd and can read/write directly to the client
- Apache swaps in a dummy socket so it doesn't close the real connection
- The daemon now owns the connection and streams the response
Client <--TCP--> Apache Worker <--Unix Socket--> Streaming Daemon
| |
| (hands off fd) | (owns connection)
v v
Worker freed Streams SSE to client
- mod_socket_handoff.c - The Apache module (~970 lines)
- Output filter that intercepts
X-Socket-Handoffheader - Passes client fd to daemon via SCM_RIGHTS
- Uses dummy socket trick to prevent Apache closing the real socket
- Caches resolved prefix at config time for performance
- Uses SOCK_NONBLOCK on Linux 2.6.27+ to reduce syscalls
- Output filter that intercepts
- apache/socket_handoff.load - Apache module loader
- apache/socket_handoff.conf - Default configuration
- examples/streaming_daemon.go - Production-ready Go daemon with goroutines
- examples/streaming_daemon.php - PHP daemon using
socket_cmsg_space() - examples/test_daemon.py - Simple Python daemon for testing
- examples/fdrecv.c - Minimal C daemon that execs any handler
The main output filter. Runs after PHP generates response:
- Checks for
X-Socket-Handoffheader - Validates socket path against allowed prefix
- Gets client fd from connection
- Connects to daemon with retry and sends fd via SCM_RIGHTS
- Swaps to dummy socket
- Marks connection as aborted
Sends file descriptor over Unix socket using sendmsg() with SCM_RIGHTS.
Uses poll() to enforce send timeout; handles EAGAIN/EWOULDBLOCK as timeout errors:
cmsg->cmsg_level = SOL_SOCKET;
cmsg->cmsg_type = SCM_RIGHTS;
cmsg->cmsg_len = CMSG_LEN(sizeof(int));
memcpy(CMSG_DATA(cmsg), &fd_to_send, sizeof(int));Connects to Unix socket using non-blocking connect with poll() timeout. Returns a non-blocking socket ready for sending.
Wraps connect_to_socket() with retry logic for transient errors (ENOENT, ECONNREFUSED). Uses exponential backoff: 10ms, 20ms, 40ms, etc.
The trick from mod_proxy_fdpass - creates a dummy socket and swaps it into the connection config so Apache closes the dummy instead of the real client socket.
Security check using realpath() to prevent path traversal attacks.
SocketHandoffEnabled On|Off # Enable/disable (default: On)
SocketHandoffAllowedPrefix /run/ # Security prefix for socket paths
SocketHandoffConnectTimeoutMs 100 # Daemon connect timeout (default: 100ms)
SocketHandoffSendTimeoutMs 200 # Daemon send timeout (default: 200ms)
SocketHandoffMaxRetries 2 # Retries for transient errors (default: 2)-
SocketHandoffConnectTimeoutMs - Timeout for establishing connection to daemon socket. Uses non-blocking connect with poll(). Range: 1-60000ms.
-
SocketHandoffSendTimeoutMs - Timeout for sending the fd to daemon via sendmsg(). Uses poll() to wait for socket buffer space before sending on the non-blocking socket. Critical for production to prevent worker starvation. Range: 1-60000ms.
- SocketHandoffMaxRetries - Number of retries for transient connection errors. Retries on ENOENT (socket doesn't exist) and ECONNREFUSED (daemon not listening). Uses exponential backoff: 10ms, 20ms, etc. Set to 0 to disable retries. Range: 0-10.
- X-Socket-Handoff (required) - Unix socket path for the daemon
- X-Handoff-Data (optional) - JSON data to pass to daemon (user_id, prompt, etc.)
<?php
// 1. Authenticate and prepare
$user = authenticate();
$data = json_encode(['user_id' => $user->id, 'prompt' => $_POST['prompt']]);
// 2. Set handoff headers
header('X-Socket-Handoff: /run/streaming-daemon.sock');
header('X-Handoff-Data: ' . $data);
// 3. Exit - module takes over
exit;A receiving daemon must:
- Listen on Unix socket - Path must match what PHP sends
- Receive fd via recvmsg() - Use SCM_RIGHTS to extract the fd
- Parse handoff data - JSON sent along with the fd
- Send HTTP response - Full response including headers (HTTP/1.1 200 OK...)
- Stream content - SSE, chunked, or regular response
- Close fd when done - Daemon owns the connection
// Receive fd
msgs, _ := syscall.ParseSocketControlMessage(oob[:oobn])
fds, _ := syscall.ParseUnixRights(&msgs[0])
clientFd := fds[0]
// IMPORTANT: os.NewFile takes ownership - use file.Close(), not syscall.Close()
file := os.NewFile(uintptr(clientFd), "client")
defer file.Close()
// Send HTTP response
writer := bufio.NewWriter(file)
fmt.Fprintf(writer, "HTTP/1.1 200 OK\r\n")
fmt.Fprintf(writer, "Content-Type: text/event-stream\r\n\r\n")// Key: Use socket_cmsg_space() for proper buffer size
$message = [
'name' => [],
'buffer_size' => 4096,
'controllen' => socket_cmsg_space(SOL_SOCKET, SCM_RIGHTS, 1),
];
socket_recvmsg($socket, $message, 0);
$client_fd = $message['control'][0]['data'][0];make # Build with apxs
sudo make install # Install to Apache modules
make enable # Enable on Debian/Ubuntu
sudo systemctl reload apache2-
Start a daemon:
cd examples go build -o streaming-daemon streaming_daemon.go sudo ./streaming-daemon -
Create test PHP endpoint:
header('X-Socket-Handoff: /run/streaming-daemon.sock'); header('X-Handoff-Data: {"prompt":"test"}'); exit;
-
Test with curl:
curl http://localhost/your-endpoint
Cause: os.NewFile() takes ownership of fd. If the file object is garbage collected, the fd is closed.
Fix: Keep file in scope with defer file.Close(), don't use syscall.Close().
Cause: Apache (www-data) can't connect to daemon socket. Fix: Set proper ownership and permissions on the socket. For example:
chown www-data:www-data /run/streaming-daemon.sock(if daemon runs as www-data)- Or add Apache user to daemon's group and use
chmod 660 - Avoid
chmod 666as it allows any local user to connect, bypassing authentication
Fix: Run sudo a2enmod socket_handoff and reload Apache.
Cause: Socket path doesn't start with allowed prefix.
Fix: Ensure path starts with SocketHandoffAllowedPrefix (default: /run/).
Cause: Daemon socket briefly unavailable during restart.
Fix: SocketHandoffMaxRetries 2 (default) handles this with exponential backoff.
Increase if daemon restarts take longer than ~30ms (10+20ms).
Cause: Daemon socket buffer full, sendmsg() timed out.
Fix: Increase SocketHandoffSendTimeoutMs or investigate why daemon isn't
reading from its socket fast enough. May indicate daemon is overloaded.
The module includes several optimizations for high-traffic deployments:
-
Prefix caching - The allowed socket prefix is resolved once at config time via
socket_handoff_post_config(). This eliminates 2-3apr_filepath_merge()calls per request. -
SOCK_NONBLOCK - On Linux 2.6.27+, the daemon socket is created with
SOCK_NONBLOCKto eliminate onefcntl()syscall per connection. -
Lower default timeout - Default connect timeout is 100ms, send timeout is 200ms. For localhost Unix sockets, these are generous. Lower timeouts prevent worker starvation when the daemon is slow.
-
poll()-based send timeout - Before sending, poll() is used to wait for socket buffer space with the configured timeout. This prevents sendmsg() from blocking indefinitely if the daemon's socket buffer is full. Critical for production - without it, a slow daemon can block Apache workers indefinitely.
-
Retry with exponential backoff - Transient errors (ENOENT, ECONNREFUSED) are retried with exponential backoff (10ms, 20ms). This handles daemon restarts gracefully without failing all in-flight requests.
-
Non-blocking throughout - The socket stays non-blocking after connect. Combined with the poll()-based connect and send timeouts, this bounds how long any operation can block the worker to the configured timeout.
- Socket prefix validation - Only sockets under allowed prefix can be used
- Path traversal prevention -
realpath()check blocks../attacks - Headers removed - X-Socket-Handoff headers are stripped before response
- Main requests only - Subrequests are not handled
- mod_proxy_fdpass - Dummy socket swap trick
- mod_xsendfile - Output filter pattern for header interception
Apache 2.0