signal.c
| 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 | |
| 53 | enum 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 | |
| 60 | struct 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 | |
| 80 | struct 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 | |
| 87 | static struct conn conns[MAX_CONNS]; |
| 88 | static struct room rooms[MAX_ROOMS]; |
| 89 | static int server_fd = -1; |
| 90 | static volatile sig_atomic_t stop_flag = 0; |
| 91 | |
| 92 | /* ------------------------------------------------------------------------ */ |
| 93 | |
| 94 | static void on_stop(int sig) { (void)sig; stop_flag = 1; } |
| 95 | |
| 96 | /* Monotonic seconds — immune to NTP steps and manual wall-clock changes. */ |
| 97 | static 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). */ |
| 105 | static 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 | |
| 114 | static 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. */ |
| 132 | static 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 | |
| 138 | static 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. */ |
| 146 | static 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(). */ |
| 154 | static 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. */ |
| 173 | static 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 | |
| 181 | static int room_index(struct room *r) { return r ? (int)(r - rooms) : -1; } |
| 182 | |
| 183 | /* ------------------------------------------------------------ connections */ |
| 184 | |
| 185 | static 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 | |
| 194 | static 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. */ |
| 211 | static 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). */ |
| 245 | static 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. */ |
| 260 | static 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. */ |
| 345 | static 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 | |
| 364 | static 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 | |
| 438 | static 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 | |
| 452 | static 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 | |
| 466 | int 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 |