Skip to content

Commit 5d0a8f5

Browse files
Add graceful shutdown of servers
1 parent b40526c commit 5d0a8f5

File tree

2 files changed

+164
-32
lines changed

2 files changed

+164
-32
lines changed

src/Core/Server.php

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ class Server
1818
private $clients = [];
1919
private $cache;
2020
private $webSocketPid;
21+
private $running = true;
22+
private $socketClosed = false; // Track if the socket has been closed
2123

2224
public function __construct(
2325
$host = '0.0.0.0',
@@ -42,6 +44,10 @@ public function getRouter()
4244

4345
public function start()
4446
{
47+
// Register signal handlers for SIGINT and SIGTERM
48+
pcntl_signal(SIGINT, [$this, 'stop']); // Handle Ctrl+C
49+
pcntl_signal(SIGTERM, [$this, 'stop']); // Handle termination signals
50+
4551
// Create a TCP/IP socket
4652
$this->socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
4753
if ($this->socket === false) {
@@ -71,14 +77,32 @@ public function start()
7177
$this->clients[] = $this->socket;
7278

7379
// Main server loop
74-
while (true) {
80+
while ($this->running) {
81+
// Dispatch any pending signals
82+
pcntl_signal_dispatch();
83+
7584
// Prepare the read array with the server socket and all client sockets
7685
$read = array_merge([$this->socket], $this->clients);
7786
$write = $except = null;
7887

7988
// Use socket_select to monitor sockets for activity
80-
if (socket_select($read, $write, $except, null) === false) {
81-
die("socket_select failed: " . socket_strerror(socket_last_error()) . "\n");
89+
$result = @socket_select($read, $write, $except, 1); // 1-second timeout
90+
91+
if ($result === false) {
92+
$error = socket_last_error();
93+
if ($error === SOCKET_EINTR) {
94+
// Interrupted by a signal, continue the loop
95+
continue;
96+
}
97+
die("socket_select failed: " . socket_strerror($error) . "\n");
98+
}
99+
100+
if ($result === 0) {
101+
// Timeout, check if we should stop
102+
if (!$this->running) {
103+
break;
104+
}
105+
continue;
82106
}
83107

84108
foreach ($read as $conn) {
@@ -133,6 +157,9 @@ public function start()
133157
}
134158
}
135159
}
160+
161+
// Clean up resources
162+
$this->stop();
136163
}
137164

138165
private function startWebSocketServer()
@@ -205,14 +232,33 @@ private function handleHttpRequest($conn, Request $request)
205232

206233
public function stop()
207234
{
208-
if ($this->socket) {
235+
if (!$this->running) {
236+
return;
237+
}
238+
239+
$this->running = false;
240+
241+
// Close all client connections
242+
foreach ($this->clients as $client) {
243+
if ($client !== $this->socket) {
244+
socket_close($client);
245+
echo "Client connection closed.\n";
246+
}
247+
}
248+
249+
// Close the server socket only if it hasn't been closed already
250+
if ($this->socket && !$this->socketClosed) {
209251
socket_close($this->socket);
210-
echo "Server stopped.\n";
252+
$this->socketClosed = true; // Mark the socket as closed
253+
echo "Server socket closed.\n";
211254
}
212255

256+
// Terminate the WebSocket server process
213257
if ($this->webSocketPid) {
214258
posix_kill($this->webSocketPid, SIGTERM);
215-
echo "WebSocket server stopped.\n";
216259
}
260+
261+
// Exit the script
262+
exit(0);
217263
}
218264
}

src/WebSocket/WebSocketServer.php

Lines changed: 112 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ class WebSocketServer implements WebSocketInterface
88
private $port;
99
private $socket;
1010
private $clients = [];
11+
private $running = true;
12+
private $socketClosed = false;
1113

1214
public function __construct($host = '0.0.0.0', $port = 8081)
1315
{
@@ -17,6 +19,10 @@ public function __construct($host = '0.0.0.0', $port = 8081)
1719

1820
public function start()
1921
{
22+
// Register signal handlers for SIGINT and SIGTERM
23+
pcntl_signal(SIGINT, [$this, 'stop']); // Handle Ctrl+C
24+
pcntl_signal(SIGTERM, [$this, 'stop']); // Handle termination signals
25+
2026
// Create a TCP/IP socket
2127
$this->socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
2228
if ($this->socket === false) {
@@ -39,14 +45,32 @@ public function start()
3945
echo "WebSocket server started on ws://{$this->host}:{$this->port}\n";
4046

4147
// Main server loop
42-
while (true) {
48+
while ($this->running) {
49+
// Dispatch any pending signals
50+
pcntl_signal_dispatch();
51+
4352
// Prepare the read array with the server socket and all client sockets
4453
$read = array_merge([$this->socket], $this->clients);
4554
$write = $except = null;
4655

4756
// Use socket_select to monitor sockets for activity
48-
if (socket_select($read, $write, $except, null) === false) {
49-
die("socket_select failed: " . socket_strerror(socket_last_error()) . "\n");
57+
$result = @socket_select($read, $write, $except, 1); // 1-second timeout
58+
59+
if ($result === false) {
60+
$error = socket_last_error();
61+
if ($error === SOCKET_EINTR) {
62+
// Interrupted by a signal, continue the loop
63+
continue;
64+
}
65+
die("socket_select failed: " . socket_strerror($error) . "\n");
66+
}
67+
68+
if ($result === 0) {
69+
// Timeout, check if we should stop
70+
if (!$this->running) {
71+
break;
72+
}
73+
continue;
5074
}
5175

5276
// Handle new connections
@@ -103,27 +127,36 @@ public function start()
103127
$this->broadcast($decodedFrame['payload']);
104128
}
105129
}
130+
131+
// Clean up resources
132+
$this->stop();
106133
}
107134

108135
public function handleConnection($socket)
109136
{
110137
$this->clients[] = $socket;
111138

112-
while (true) {
113-
114-
$data = socket_read($socket, 8192, PHP_BINARY_READ);
115-
if ($data === false) {
116-
echo "socket_read failed: " . socket_strerror(socket_last_error()) . "\n";
117-
$this->removeClient($socket);
139+
foreach ($this->clients as $client) {
140+
$data = @socket_read($client, 8192, PHP_BINARY_READ);
141+
if ($data === false || $data === '') {
142+
// Client disconnected
143+
$this->removeClient($client);
118144
continue;
119145
}
120146

121147
// Decode the WebSocket frame
122148
$decodedFrame = $this->decodeWebSocketFrame($data);
123149
if ($decodedFrame === null) {
124150
echo "Invalid WebSocket frame received.\n";
125-
$this->removeClient($socket);
126-
break;
151+
$this->removeClient($client);
152+
continue;
153+
}
154+
155+
// Handle close frame from the client
156+
if ($decodedFrame['opcode'] === 0x8) { // 0x8 = close frame
157+
echo "Client sent a close frame. Closing connection.\n";
158+
$this->removeClient($client);
159+
continue;
127160
}
128161

129162
// Log the decoded frame
@@ -133,7 +166,16 @@ public function handleConnection($socket)
133166

134167
// Send a response back to the client
135168
$responseFrame = $this->encodeWebSocketFrame("Server received: " . $decodedFrame['payload']);
136-
@socket_write($socket, $responseFrame, strlen($responseFrame)); // Suppress warnings
169+
if (!@socket_write($client, $responseFrame, strlen($responseFrame))) {
170+
$error = socket_last_error($client);
171+
if ($error === SOCKET_EPIPE) {
172+
echo "Socket is already closed may be by client. Skipping response.\n";
173+
} else {
174+
echo "socket_write failed: " . socket_strerror($error) . "\n";
175+
}
176+
$this->removeClient($client);
177+
continue;
178+
}
137179
echo "Sent response to client.\n";
138180

139181
// Broadcast the message to all clients
@@ -150,10 +192,6 @@ public function handshake($socket)
150192
return false;
151193
}
152194

153-
// Debug: Output the handshake request
154-
echo "Handshake request received:\n";
155-
echo $request . "\n";
156-
157195
// Extract the WebSocket key from the request headers
158196
if (preg_match('/Sec-WebSocket-Key: (.*)\r\n/', $request, $matches)) {
159197
$key = trim($matches[1]);
@@ -171,10 +209,6 @@ public function handshake($socket)
171209
$response .= "Connection: Upgrade\r\n";
172210
$response .= "Sec-WebSocket-Accept: $acceptKey\r\n\r\n";
173211

174-
// Debug: Output the handshake response
175-
echo "Handshake response:\n";
176-
echo $response . "\n";
177-
178212
// Send the handshake response
179213
if (!socket_write($socket, $response, strlen($response))) {
180214
echo "Failed to send handshake response.\n";
@@ -189,8 +223,14 @@ public function removeClient($socket)
189223
{
190224
$index = array_search($socket, $this->clients);
191225
if ($index !== false) {
192-
unset($this->clients[$index]);
226+
// Send a close frame before closing the connection
227+
$this->sendCloseFrame($socket, 1000, 'Server closing connection');
228+
229+
// Close the socket
193230
socket_close($socket);
231+
232+
// Remove the client from the clients array
233+
unset($this->clients[$index]);
194234
echo "Client removed. Total clients: " . count($this->clients) . "\n";
195235
}
196236
}
@@ -255,13 +295,13 @@ public function unmaskPayload($payload, $maskingKey)
255295
return $unmaskedPayload;
256296
}
257297

258-
public function encodeWebSocketFrame($payload)
298+
public function encodeWebSocketFrame($payload, $opcode = 0x81)
259299
{
260300
$frame = '';
261301
$payloadLength = strlen($payload);
262302

263-
// Set the first byte (opcode and flags)
264-
$frame .= chr(0x81); // 0x81 = text frame (FIN bit set)
303+
// Set the first byte (FIN bit and opcode)
304+
$frame .= chr(0x80 | $opcode); // FIN bit set (0x80) + opcode
265305

266306
// Set the second byte (mask and payload length)
267307
if ($payloadLength <= 125) {
@@ -278,11 +318,57 @@ public function encodeWebSocketFrame($payload)
278318
return $frame;
279319
}
280320

321+
public function sendCloseFrame($socket, $statusCode = 1000, $reason = '')
322+
{
323+
// Check if the socket is still writable
324+
if (socket_last_error($socket) === SOCKET_EPIPE) {
325+
echo "Socket is already closed. Skipping close frame.\n";
326+
return;
327+
}
328+
329+
// Pack the status code into 2 bytes
330+
$statusCode = pack('n', $statusCode);
331+
332+
// Combine the status code and reason
333+
$payload = $statusCode . $reason;
334+
335+
// Create the close frame
336+
$closeFrame = $this->encodeWebSocketFrame($payload, 0x88); // 0x88 = close frame
337+
338+
// Send the close frame
339+
if (!@socket_write($socket, $closeFrame, strlen($closeFrame))) {
340+
$error = socket_last_error($socket);
341+
if ($error === SOCKET_EPIPE) {
342+
echo "Socket is already closed may be by client. Skipping close frame.\n";
343+
} else {
344+
echo "Failed to send close frame: " . socket_strerror($error) . "\n";
345+
}
346+
}
347+
}
348+
281349
public function stop()
282350
{
283-
if ($this->socket) {
351+
if (!$this->running) {
352+
return;
353+
}
354+
355+
$this->running = false;
356+
357+
// Send close frames to all clients
358+
foreach ($this->clients as $client) {
359+
$this->sendCloseFrame($client, 1000, 'Server shutting down');
360+
socket_close($client);
361+
}
362+
echo "Clients connections closed.\n";
363+
364+
// Close the server socket only if it hasn't been closed already
365+
if ($this->socket && !$this->socketClosed) {
284366
socket_close($this->socket);
285-
echo "WebSocket server stopped.\n";
367+
$this->socketClosed = true; // Mark the socket as closed
368+
echo "WebSocket server socket closed.\n";
286369
}
370+
371+
// Exit the script
372+
exit(0);
287373
}
288374
}

0 commit comments

Comments
 (0)