signal.c
Raw
1/* signal.c — single-threaded WebRTC room-code signaling server.
2 *
3 * Endpoints (all bodies opaque to the server):
4 * POST /room/<code>/offer store offer, wake any waiters
5 * GET /room/<code>/offer return offer (long-poll up to 10 s, 204 on timeout)
6 * POST /room/<code>/answer store answer, wake any waiters
7 * GET /room/<code>/answer return answer (long-poll up to 10 s, 204 on timeout)
8 * OPTIONS * CORS preflight
9 *
10 * Build: cc -O2 signal.c -o signal
11 * Run: ./signal [port] (defaults to 8080)
12 *
13 * The whole server runs on one thread with poll(). Connections are state
14 * machines (READ → WAIT → WRITE → done). A waiting GET is parked with no
15 * IO interest until a matching POST flips it to WRITE. The long-poll
16 * timeout returns 204 No Content
17 *
18 * Portability: strict POSIX.1-2008
19 */
20
21#ifndef _POSIX_C_SOURCE
22#define _POSIX_C_SOURCE 200809L
23#endif
24#include <stdio.h>
25#include <stdlib.h>
26#include <stdint.h>
27#include <string.h>
28#include <strings.h>
29#include <unistd.h>
30#include <fcntl.h>
31#include <errno.h>
32#include <limits.h>
33#include <time.h>
34#include <signal.h>
35#include <poll.h>
36#include <sys/socket.h>
37#include <sys/resource.h>
38#include <netinet/in.h>
39
40#define MAX_ROOMS 1024
41#define MAX_CONNS (MAX_ROOMS * 2) /* worst case: both peers parked on the same room */
42#define MAX_BLOB (64 * 1024)
43#define MAX_HEADER 4096
44#define RBUF_SIZE (MAX_HEADER + MAX_BLOB)
45#define ROOM_TTL_S 300
46#define GC_INTERVAL_S 60
47#define LONGPOLL_S 10
48#define IDLE_TIMEOUT_S 15
49#define CODE_MAX 15
50#define METHOD_MAX 7
51#define PATH_MAX_LEN 79
52
53enum cstate {
54 C_FREE = 0,
55 C_READ, /* reading request headers + body */
56 C_WAIT, /* long-polling for a slot to fill */
57 C_WRITE, /* sending response */
58};
59
60struct conn {
61 int fd;
62 enum cstate state;
63 time_t deadline; /* absolute time after which this conn is cleaned up */
64
65 char *rbuf; /* allocated lazily on first read, freed on release */
66 size_t rlen;
67 size_t body_off; /* offset of body start in rbuf (0 = headers not parsed yet) */
68 size_t body_want; /* Content-Length */
69
70 char method[METHOD_MAX + 1];
71 char path[PATH_MAX_LEN + 1];
72
73 char *wbuf;
74 size_t wlen, woff;
75
76 int wait_room; /* index into rooms[] when C_WAIT, else -1 */
77 char wait_slot; /* 'o' or 'a' when C_WAIT */
78};
79
80struct room {
81 char code[CODE_MAX + 1];
82 char *offer; size_t offer_n;
83 char *answer; size_t answer_n;
84 time_t touched;
85};
86
87static struct conn conns[MAX_CONNS];
88static struct room rooms[MAX_ROOMS];
89static int server_fd = -1;
90static volatile sig_atomic_t stop_flag = 0;
91
92/* ------------------------------------------------------------------------ */
93
94static void on_stop(int sig) { (void)sig; stop_flag = 1; }
95
96/* Monotonic seconds — immune to NTP steps and manual wall-clock changes. */
97static time_t monotonic_now(void) {
98 struct timespec ts;
99 clock_gettime(CLOCK_MONOTONIC, &ts);
100 return ts.tv_sec;
101}
102
103/* Case-insensitive substring search. Replaces the GNU strcasestr() so the
104 * file compiles under strict POSIX (-D_POSIX_C_SOURCE=200809L, no GNU). */
105static char *ci_strstr(const char *hay, const char *needle) {
106 size_t nlen = strlen(needle);
107 if (nlen == 0) return (char *)hay;
108 for (; *hay; hay++) {
109 if (strncasecmp(hay, needle, nlen) == 0) return (char *)hay;
110 }
111 return NULL;
112}
113
114static const char *status_text(int s) {
115 switch (s) {
116 case 200: return "OK";
117 case 204: return "No Content";
118 case 400: return "Bad Request";
119 case 404: return "Not Found";
120 case 405: return "Method Not Allowed";
121 case 408: return "Request Timeout";
122 case 413: return "Payload Too Large";
123 case 414: return "URI Too Long";
124 case 503: return "Service Unavailable";
125 default: return "Error";
126 }
127}
128
129/* Returns 0 on success, -1 on failure. We bail rather than continue with a
130 * blocking socket: in the accept loop a blocking fd would freeze the server
131 * on the second accept after the kernel's queue is drained. */
132static int set_nonblock(int fd) {
133 int f = fcntl(fd, F_GETFL, 0);
134 if (f < 0) return -1;
135 return fcntl(fd, F_SETFL, f | O_NONBLOCK);
136}
137
138static void set_cloexec(int fd) {
139 int f = fcntl(fd, F_GETFD, 0);
140 if (f >= 0) fcntl(fd, F_SETFD, f | FD_CLOEXEC);
141}
142
143/* ------------------------------------------------------------------ rooms */
144
145/* Free both SDP blobs and clear the slot so room_get can reuse it. */
146static void room_release(struct room *r) {
147 free(r->offer); r->offer = NULL; r->offer_n = 0;
148 free(r->answer); r->answer = NULL; r->answer_n = 0;
149 r->code[0] = 0;
150}
151
152/* Find a room by code, optionally creating it in an empty slot. Touched is
153 * bumped on every hit; expiry is handled separately by gc_rooms(). */
154static struct room *room_get(const char *code, int create) {
155 time_t now = monotonic_now();
156 struct room *empty = NULL, *found = NULL;
157 for (int i = 0; i < MAX_ROOMS; i++) {
158 struct room *r = &rooms[i];
159 if (r->code[0] == 0) { if (!empty) empty = r; continue; }
160 if (strcmp(r->code, code) == 0) { found = r; break; }
161 }
162 if (!found && create && empty) {
163 memset(empty, 0, sizeof(*empty));
164 strncpy(empty->code, code, CODE_MAX);
165 found = empty;
166 }
167 if (found) found->touched = now;
168 return found;
169}
170
171/* Reclaim rooms that haven't been touched for ROOM_TTL_S. Called once per
172 * GC_INTERVAL_S from the main loop, independent of request traffic. */
173static void gc_rooms(void) {
174 time_t now = monotonic_now();
175 for (int i = 0; i < MAX_ROOMS; i++) {
176 struct room *r = &rooms[i];
177 if (r->code[0] && now - r->touched > ROOM_TTL_S) room_release(r);
178 }
179}
180
181static int room_index(struct room *r) { return r ? (int)(r - rooms) : -1; }
182
183/* ------------------------------------------------------------ connections */
184
185static void conn_release(struct conn *c) {
186 if (c->fd >= 0) close(c->fd);
187 free(c->rbuf);
188 free(c->wbuf);
189 memset(c, 0, sizeof(*c));
190 c->fd = -1;
191 c->wait_room = -1;
192}
193
194static struct conn *conn_alloc(int fd) {
195 for (int i = 0; i < MAX_CONNS; i++) {
196 struct conn *c = &conns[i];
197 if (c->state == C_FREE && c->fd <= 0) {
198 memset(c, 0, sizeof(*c));
199 c->fd = fd;
200 c->state = C_READ;
201 c->deadline = monotonic_now() + IDLE_TIMEOUT_S;
202 c->wait_room = -1;
203 return c;
204 }
205 }
206 return NULL;
207}
208
209/* Stage a response on the connection (the body is copied into a freshly
210 * allocated send buffer). Moves the conn to C_WRITE. */
211static void respond(struct conn *c, int status, const char *ctype,
212 const void *body, size_t body_n) {
213 char hdr[320];
214 int hn = snprintf(hdr, sizeof(hdr),
215 "HTTP/1.1 %d %s\r\n"
216 "Content-Type: %s\r\n"
217 "Content-Length: %zu\r\n"
218 "Access-Control-Allow-Origin: *\r\n"
219 "Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n"
220 "Access-Control-Allow-Headers: Content-Type\r\n"
221 "Cache-Control: no-store\r\n"
222 "Connection: close\r\n\r\n",
223 status, status_text(status), ctype, body_n);
224
225 char *buf = malloc(hn + body_n);
226 if (!buf) { conn_release(c); return; }
227 memcpy(buf, hdr, hn);
228 if (body_n) memcpy(buf + hn, body, body_n);
229
230 free(c->wbuf);
231 c->wbuf = buf;
232 c->wlen = hn + body_n;
233 c->woff = 0;
234 c->state = C_WRITE;
235 c->deadline = monotonic_now() + IDLE_TIMEOUT_S;
236 c->wait_room = -1;
237}
238
239/* Match-and-wake all C_WAIT connections that are parked on this room/slot.
240 * If the answer was just delivered to at least one waiter, the handshake is
241 * complete — drop the room. respond() has already copied `blob` into each
242 * waker's write buffer by then, so freeing the room's answer is safe. If
243 * nobody was parked, the blob stays so a later GET can still pick it up
244 * (and that GET path releases the room itself). */
245static void wake_waiters(int ri, char slot, const void *blob, size_t n) {
246 int woke = 0;
247 for (int i = 0; i < MAX_CONNS; i++) {
248 struct conn *w = &conns[i];
249 if (w->state == C_WAIT && w->wait_room == ri && w->wait_slot == slot) {
250 respond(w, 200, "application/sdp", blob, n);
251 woke = 1;
252 }
253 }
254 if (slot == 'a' && woke) room_release(&rooms[ri]);
255}
256
257/* ------------------------------------------------------------- dispatch */
258
259/* Called once headers + body are fully buffered. */
260static void dispatch(struct conn *c) {
261 if (strcmp(c->method, "OPTIONS") == 0) {
262 respond(c, 204, "text/plain", "", 0);
263 return;
264 }
265
266 /* Liveness probe — clients hit this to confirm the relay is reachable
267 * before committing to the full handshake. Cheap: no allocation, no
268 * room access. */
269 if (strcmp(c->path, "/health") == 0) {
270 if (strcmp(c->method, "GET") != 0 && strcmp(c->method, "HEAD") != 0) {
271 respond(c, 405, "text/plain", "no", 2);
272 return;
273 }
274 respond(c, 200, "text/plain", "ok\n", 3);
275 return;
276 }
277
278 char code[CODE_MAX + 1] = {0};
279 char slot[8] = {0};
280 if (sscanf(c->path, "/room/%15[A-Za-z0-9_-]/%7[a-z]", code, slot) != 2) {
281 respond(c, 404, "text/plain", "no", 2);
282 return;
283 }
284 int is_offer = !strcmp(slot, "offer");
285 int is_answer = !strcmp(slot, "answer");
286 if (!is_offer && !is_answer) {
287 respond(c, 404, "text/plain", "no", 2);
288 return;
289 }
290 char slot_ch = is_offer ? 'o' : 'a';
291
292 if (!strcmp(c->method, "POST")) {
293 struct room *r = room_get(code, 1);
294 if (!r) { respond(c, 503, "text/plain", "full", 4); return; }
295
296 char **blob = is_offer ? &r->offer : &r->answer;
297 size_t *blen = is_offer ? &r->offer_n : &r->answer_n;
298
299 /* malloc(0) is implementation-defined; round up to 1 byte so an
300 * empty body POST doesn't masquerade as OOM. */
301 size_t alloc_n = c->body_want ? c->body_want : 1;
302 char *copy = malloc(alloc_n);
303 if (!copy) { respond(c, 503, "text/plain", "oom", 3); return; }
304 if (c->body_want) memcpy(copy, c->rbuf + c->body_off, c->body_want);
305
306 free(*blob);
307 *blob = copy;
308 *blen = c->body_want;
309
310 wake_waiters(room_index(r), slot_ch, *blob, *blen);
311 respond(c, 204, "text/plain", "", 0);
312 return;
313 }
314
315 if (!strcmp(c->method, "GET")) {
316 struct room *r = room_get(code, 1);
317 if (!r) { respond(c, 503, "text/plain", "full", 4); return; }
318
319 char *blob = is_offer ? r->offer : r->answer;
320 size_t blen = is_offer ? r->offer_n : r->answer_n;
321 if (blob) {
322 respond(c, 200, "application/sdp", blob, blen);
323 /* Answer delivery completes the SDP exchange — peers go P2P now,
324 * so the relay can drop the room. The offer slot stays in case
325 * the joiner needs to refetch after a crash/retry. */
326 if (is_answer) room_release(r);
327 return;
328 }
329 /* Park the connection: no IO interest, just sit on the deadline. */
330 c->state = C_WAIT;
331 c->wait_room = room_index(r);
332 c->wait_slot = slot_ch;
333 c->deadline = monotonic_now() + LONGPOLL_S;
334 return;
335 }
336
337 respond(c, 405, "text/plain", "no", 2);
338}
339
340/* ------------------------------------------------------------- IO drivers */
341
342/* Parse "METHOD SP PATH SP HTTP/x.y" without mutating rbuf — the subsequent
343 * Content-Length scan needs the header block intact. Returns 0 on success,
344 * 400 on malformed, 414 on field overflow. */
345static int parse_request_line(struct conn *c, const char *hdr_end) {
346 const char *p = c->rbuf;
347 const char *sp1 = memchr(p, ' ', (size_t)(hdr_end - p));
348 if (!sp1) return 400;
349 size_t mlen = (size_t)(sp1 - p);
350 if (mlen > METHOD_MAX) return 414;
351 memcpy(c->method, p, mlen);
352 c->method[mlen] = 0;
353
354 p = sp1 + 1;
355 const char *sp2 = memchr(p, ' ', (size_t)(hdr_end - p));
356 if (!sp2) return 400;
357 size_t plen = (size_t)(sp2 - p);
358 if (plen > PATH_MAX_LEN) return 414;
359 memcpy(c->path, p, plen);
360 c->path[plen] = 0;
361 return 0;
362}
363
364static void on_readable(struct conn *c) {
365 if (!c->rbuf) {
366 c->rbuf = malloc(RBUF_SIZE);
367 if (!c->rbuf) { conn_release(c); return; }
368 }
369 for (;;) {
370 size_t cap = RBUF_SIZE - c->rlen;
371 if (cap == 0) { conn_release(c); return; }
372 ssize_t n = recv(c->fd, c->rbuf + c->rlen, cap, 0);
373 if (n == 0) { conn_release(c); return; }
374 if (n < 0) {
375 if (errno == EAGAIN || errno == EWOULDBLOCK) return;
376 conn_release(c); return;
377 }
378 c->rlen += n;
379 c->deadline = monotonic_now() + IDLE_TIMEOUT_S;
380
381 /* Parse headers once \r\n\r\n appears within the first MAX_HEADER bytes. */
382 if (c->body_off == 0) {
383 size_t scan = c->rlen < MAX_HEADER ? c->rlen : MAX_HEADER;
384 char *eoh = NULL;
385 for (size_t i = 0; i + 3 < scan; i++) {
386 if (c->rbuf[i] == '\r' && c->rbuf[i+1] == '\n' &&
387 c->rbuf[i+2] == '\r' && c->rbuf[i+3] == '\n') { eoh = c->rbuf + i; break; }
388 }
389 if (!eoh) {
390 if (c->rlen >= MAX_HEADER) respond(c, 413, "text/plain", "header too large", 16);
391 return;
392 }
393 *eoh = 0; /* terminate header block for ci_strstr */
394 c->body_off = (size_t)(eoh - c->rbuf) + 4;
395
396 int err = parse_request_line(c, eoh);
397 if (err == 400) { respond(c, 400, "text/plain", "bad request", 11); return; }
398 if (err == 414) { respond(c, 414, "text/plain", "uri too long", 12); return; }
399
400 /* Parse Content-Length line-anchored to avoid matching header
401 * names that merely *contain* "Content-Length:" as a substring
402 * (e.g. X-Original-Content-Length). RFC 7230 §3.3.2 requires
403 * rejecting duplicate Content-Length headers — important for
404 * direct-bind deployments not fronted by a normalizing proxy. */
405 int cl_seen = 0;
406 const char *needle = "\nContent-Length:";
407 const size_t needle_n = 16;
408 for (char *p = c->rbuf; (p = ci_strstr(p, needle)) != NULL; p += needle_n) {
409 if (cl_seen++) {
410 respond(c, 400, "text/plain", "duplicate content-length", 24);
411 return;
412 }
413 const char *v = p + needle_n;
414 while (*v == ' ' || *v == '\t') v++;
415 if (*v == '-' || *v == '+') {
416 respond(c, 400, "text/plain", "bad content-length", 18);
417 return;
418 }
419 char *end;
420 errno = 0;
421 unsigned long len = strtoul(v, &end, 10);
422 if (end == v || errno == ERANGE) {
423 respond(c, 400, "text/plain", "bad content-length", 18);
424 return;
425 }
426 c->body_want = len;
427 }
428 if (c->body_want > MAX_BLOB) {
429 respond(c, 413, "text/plain", "body too large", 14); return;
430 }
431 }
432
433 if (c->rlen - c->body_off >= c->body_want) { dispatch(c); return; }
434 /* else loop and read more */
435 }
436}
437
438static void on_writable(struct conn *c) {
439 while (c->woff < c->wlen) {
440 /* SIGPIPE is suppressed via sigaction(SIGPIPE, SIG_IGN), so plain
441 * send() is enough — no need for the non-portable MSG_NOSIGNAL. */
442 ssize_t n = send(c->fd, c->wbuf + c->woff, c->wlen - c->woff, 0);
443 if (n < 0) {
444 if (errno == EAGAIN || errno == EWOULDBLOCK) return;
445 conn_release(c); return;
446 }
447 c->woff += n;
448 }
449 conn_release(c);
450}
451
452static void accept_new(void) {
453 for (;;) {
454 int cfd = accept(server_fd, NULL, NULL);
455 if (cfd < 0) {
456 if (errno == EAGAIN || errno == EWOULDBLOCK) return;
457 return;
458 }
459 set_cloexec(cfd);
460 if (set_nonblock(cfd) < 0 || !conn_alloc(cfd)) close(cfd); /* full or fcntl failed */
461 }
462}
463
464/* ------------------------------------------------------------------ main */
465
466int main(int argc, char **argv) {
467 int port = 8080;
468 if (argc > 1) {
469 char *end;
470 errno = 0;
471 long lport = strtol(argv[1], &end, 10);
472 if (errno || *end || lport < 1 || lport > 65535) {
473 fprintf(stderr, "bad port: %s\n", argv[1]);
474 return 1;
475 }
476 port = (int)lport;
477 }
478
479 /* sigaction() has portable, well-defined semantics across POSIX systems;
480 * signal()'s behavior is historically split SysV/BSD. */
481 struct sigaction sa;
482 memset(&sa, 0, sizeof(sa));
483 sa.sa_handler = SIG_IGN;
484 sigaction(SIGPIPE, &sa, NULL);
485 sa.sa_handler = on_stop;
486 sigaction(SIGINT, &sa, NULL);
487 sigaction(SIGTERM, &sa, NULL);
488
489 /* Raise RLIMIT_NOFILE up to what MAX_CONNS needs (plus a few for stdio /
490 * the listen socket). If the hard cap is below that, warn — accepts will
491 * silently fail at the kernel layer otherwise. */
492 struct rlimit rl;
493 if (getrlimit(RLIMIT_NOFILE, &rl) == 0) {
494 rlim_t need = (rlim_t)MAX_CONNS + 16;
495 if (rl.rlim_cur < need) {
496 rl.rlim_cur = rl.rlim_max < need ? rl.rlim_max : need;
497 setrlimit(RLIMIT_NOFILE, &rl);
498 if (rl.rlim_cur < need) {
499 fprintf(stderr, "warning: nofile soft limit %lu < %lu — capacity reduced\n",
500 (unsigned long)rl.rlim_cur, (unsigned long)need);
501 }
502 }
503 }
504
505 server_fd = socket(AF_INET, SOCK_STREAM, 0);
506 if (server_fd < 0) { perror("socket"); return 1; }
507 set_cloexec(server_fd);
508
509 int one = 1;
510 if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)) < 0) {
511 perror("setsockopt SO_REUSEADDR"); /* non-fatal */
512 }
513
514 struct sockaddr_in addr = {
515 .sin_family = AF_INET,
516 .sin_addr.s_addr = INADDR_ANY,
517 .sin_port = htons((uint16_t)port)
518 };
519 if (bind(server_fd, (struct sockaddr *)&addr, sizeof(addr)) ||
520 listen(server_fd, 64)) {
521 perror("bind/listen"); return 1;
522 }
523 /* Flip the listener non-blocking only just before entering the accept
524 * loop — bind/listen don't care, and this keeps the setup order
525 * conventional. */
526 if (set_nonblock(server_fd) < 0) { perror("fcntl"); return 1; }
527
528 fprintf(stderr, "signaling server listening on :%d\n", port);
529
530 for (int i = 0; i < MAX_CONNS; i++) { conns[i].fd = -1; conns[i].wait_room = -1; }
531
532 time_t last_gc = monotonic_now();
533 struct pollfd pfds[MAX_CONNS + 1];
534 int pidx[MAX_CONNS + 1]; /* maps pfd index → conns[] index */
535
536 while (!stop_flag) {
537 time_t now = monotonic_now();
538 int nfd = 0;
539 int timeout_ms = 1000;
540 pfds[nfd++] = (struct pollfd){ .fd = server_fd, .events = POLLIN };
541
542 /* Single sweep over the conn table: fire expired deadlines, build
543 * the pfds array, and compute the poll timeout in one pass. */
544 for (int i = 0; i < MAX_CONNS; i++) {
545 struct conn *c = &conns[i];
546 if (c->state == C_FREE || c->fd < 0) continue;
547
548 if (c->deadline <= now) {
549 /* C_WAIT → 204 No Content (not 408: Firefox auto-retries 408
550 * internally per RFC 7231 §6.5.7 and only surfaces a single
551 * fetch attempt to JS, eventually giving up with a
552 * CORS-adjacent NetworkError. 204 carries no retry semantics). */
553 if (c->state == C_WAIT) {
554 respond(c, 204, "text/plain", "", 0);
555 /* respond() moved this conn to C_WRITE with a fresh
556 * deadline; fall through to register POLLOUT. */
557 } else {
558 conn_release(c);
559 continue;
560 }
561 }
562
563 short ev = 0;
564 switch (c->state) {
565 case C_READ: ev = POLLIN; break;
566 case C_WRITE: ev = POLLOUT; break;
567 /* WAIT: register POLLIN so a client disconnect (read-side EOF)
568 * surfaces as a poll event; we never actually read while parked. */
569 case C_WAIT: ev = POLLIN; break;
570 default: continue;
571 }
572 pfds[nfd] = (struct pollfd){ .fd = c->fd, .events = ev };
573 pidx[nfd] = i;
574 nfd++;
575
576 long dt = (long)(c->deadline - now) * 1000;
577 if (dt < 0) dt = 0;
578 if (dt < timeout_ms) timeout_ms = (int)dt;
579 }
580
581 int rc = poll(pfds, nfd, timeout_ms);
582 if (rc < 0) {
583 if (errno == EINTR) continue;
584 perror("poll"); break;
585 }
586
587 if (pfds[0].revents & POLLIN) accept_new();
588
589 for (int k = 1; k < nfd; k++) {
590 short rev = pfds[k].revents;
591 if (!rev) continue;
592 struct conn *c = &conns[pidx[k]];
593 /* The conn may have been released this iteration (e.g. via wake);
594 * stale entries are no-ops since fd would be -1. */
595 if (c->fd != pfds[k].fd) continue;
596
597 if (c->state == C_WAIT) {
598 /* We set Connection: close on every response, so a parked
599 * GET should send nothing further until the next request.
600 * Any POLLIN means either the client closed (recv == 0) or
601 * it pipelined / sent garbage — drop the conn either way. */
602 if (rev & (POLLIN | POLLHUP | POLLERR | POLLNVAL)) conn_release(c);
603 continue;
604 }
605 if (rev & (POLLERR | POLLNVAL)) { conn_release(c); continue; }
606 if (c->state == C_READ && (rev & POLLIN)) on_readable(c);
607 else if (c->state == C_WRITE && (rev & POLLOUT)) on_writable(c);
608 else if (rev & POLLHUP) conn_release(c);
609 }
610
611 /* Periodic room GC. The poll timeout is clamped to 1 s so this fires
612 * on a wall-clock cadence even when the server is otherwise idle. */
613 time_t tick = monotonic_now();
614 if (tick - last_gc >= GC_INTERVAL_S) { gc_rooms(); last_gc = tick; }
615 }
616
617 fprintf(stderr, "signaling server shutting down\n");
618 if (server_fd >= 0) close(server_fd);
619 for (int i = 0; i < MAX_CONNS; i++) {
620 if (conns[i].fd >= 0) conn_release(&conns[i]);
621 }
622 for (int i = 0; i < MAX_ROOMS; i++) {
623 if (rooms[i].code[0]) room_release(&rooms[i]);
624 }
625 return 0;
626}
627