hyperion-rb 2.11.0 → 2.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -100,9 +100,20 @@
100
100
  #include <fcntl.h>
101
101
  #include <pthread.h>
102
102
  #include <dirent.h>
103
+ #include <signal.h>
103
104
  #include <sys/stat.h>
104
105
  #include <sys/types.h>
105
106
  #include <sys/time.h>
107
+ #include <sys/socket.h>
108
+ #include <netinet/in.h>
109
+ #include <netinet/tcp.h>
110
+ #include <arpa/inet.h>
111
+
112
+ /* Internal sharing surface — extern wrappers around the static helpers
113
+ * below, exposed to the 2.12-D io_uring sibling translation unit
114
+ * (`io_uring_loop.c`). The wrappers are defined in this file (next to
115
+ * the static helpers they wrap) and declared in `page_cache_internal.h`. */
116
+ #include "page_cache_internal.h"
106
117
 
107
118
  /* Shared identifiers / refs. parser.c and sendfile.c each register their
108
119
  * own copies of Hyperion / Hyperion::Http (lazy-define if missing); we
@@ -1061,12 +1072,992 @@ static VALUE rb_pc_auto_threshold(VALUE self) {
1061
1072
  return INT2NUM(HYP_PC_AUTO_THRESHOLD);
1062
1073
  }
1063
1074
 
1075
+ /* ============================================================
1076
+ * 2.12-C — Connection lifecycle in C.
1077
+ *
1078
+ * `run_static_accept_loop(listen_fd, idle_max_ms)` runs an accept ->
1079
+ * read-headers -> route-lookup -> write loop ENTIRELY in C for a
1080
+ * designated listening socket. Ruby is re-entered ONLY for:
1081
+ *
1082
+ * 1. Lifecycle hooks (when `lifecycle_hooks_active?` is true; gated
1083
+ * by a single `int` test so the no-hook hot path stays branch-
1084
+ * free) — one `rb_funcall` per request via the registered
1085
+ * `lifecycle_callback`.
1086
+ *
1087
+ * 2. Handoff: when the accepted connection's first request doesn't
1088
+ * match a `StaticEntry` (or the request is malformed / has a
1089
+ * body / is HTTP/1.0 close / requests an upgrade), the C loop
1090
+ * invokes the registered `handoff_callback` with `(fd_int,
1091
+ * partial_buffer_or_nil)` and continues to the next accept.
1092
+ * Ruby owns the fd from that point on.
1093
+ *
1094
+ * 3. Stop: when the listening fd returns EBADF / ECONNABORTED in a
1095
+ * way that suggests `Server#stop` closed it, the loop returns
1096
+ * the served-request count cleanly.
1097
+ *
1098
+ * Per-connection cost on a hit:
1099
+ * * 1 `accept` syscall (GVL released).
1100
+ * * 1 `setsockopt(TCP_NODELAY)` (mirrors 2.10-G; Nagle off so small
1101
+ * responses aren't waiting on the peer's delayed-ACK timer).
1102
+ * * 1 `recv` syscall to read the request headers (GVL released).
1103
+ * * 1 `write` syscall for the prebuilt response (GVL released).
1104
+ * * 0 Ruby allocations on the hot path past the served-count
1105
+ * accumulator (a Fixnum in the Ruby-visible return value).
1106
+ *
1107
+ * Wire format expectations:
1108
+ * * The request must be HTTP/1.1 (HTTP/1.0 is handed back to Ruby —
1109
+ * keep-alive defaulting differs and the existing connection.rb
1110
+ * already implements this correctly).
1111
+ * * No request body (`Content-Length` / `Transfer-Encoding` headers
1112
+ * trigger handoff). The whole point of `handle_static` is GETs/
1113
+ * HEADs without a body; anything else is operator misuse and the
1114
+ * Ruby path can produce a more diagnostic error.
1115
+ * * Method must be GET or HEAD (matches `serve_request`'s gate).
1116
+ *
1117
+ * Concurrency: this function MUST be called on a Ruby thread that
1118
+ * owns the GVL. The loop releases the GVL during the blocking
1119
+ * syscalls and re-acquires it for the lifecycle / handoff
1120
+ * callbacks; the C-side PageCache lock (`hyp_pc_lock`) is taken
1121
+ * only for the lookup snapshot and released before the write
1122
+ * (the same pattern `serve_request` already uses).
1123
+ * ============================================================ */
1124
+
1125
+ /* Per-process state for the lifecycle / handoff callbacks. The
1126
+ * callbacks themselves are mark-protected via `rb_gc_register_mark_object`
1127
+ * so they survive across the GC even though they're stored in
1128
+ * static globals. */
1129
+ static VALUE hyp_cl_lifecycle_callback = Qnil;
1130
+ static VALUE hyp_cl_handoff_callback = Qnil;
1131
+ static int hyp_cl_lifecycle_active = 0;
1132
+
1133
+ /* Stop flag — flipped from Ruby via `stop_accept_loop` when the
1134
+ * listener should drop out of the loop voluntarily (graceful
1135
+ * shutdown). The accept syscall is still blocking; the operator's
1136
+ * `Server#stop` close()s the listener, which races us out via
1137
+ * `accept` returning EBADF / EINVAL. The flag is the secondary
1138
+ * signal: between two requests on a keep-alive connection we
1139
+ * check it before the next read. */
1140
+ static volatile sig_atomic_t hyp_cl_stop = 0;
1141
+
1142
+ /* 2.12-E — per-process served-request counter, ticked once per request
1143
+ * served by `hyp_cl_serve_connection` (2.12-C accept4 loop) AND by the
1144
+ * 2.12-D io_uring loop (via the `pc_internal_*` shim). Read by Ruby at
1145
+ * scrape time via `Hyperion::Http::PageCache.c_loop_requests_total` —
1146
+ * the PrometheusExporter folds it into the
1147
+ * `hyperion_requests_dispatch_total{worker_id=PID}` series for the
1148
+ * current worker, so operators see one consistent per-worker number
1149
+ * regardless of which dispatch shape served the request.
1150
+ *
1151
+ * Atomicity: accessed via `__atomic_*` builtins (gcc/clang both
1152
+ * support these on every target this gem builds for). The hot-path
1153
+ * cost is one `lock add`-style instruction per request, well below
1154
+ * the ~10μs per-request budget at 134k r/s.
1155
+ *
1156
+ * Reset semantics: zeroed on `run_static_accept_loop` /
1157
+ * `run_static_io_uring_loop` entry so a previous loop's count from a
1158
+ * test-suite respawn doesn't leak into the new loop's snapshot.
1159
+ * Specs use `reset_c_loop_requests_total!` to assert from-zero
1160
+ * behaviour without driving a full loop.
1161
+ *
1162
+ * Type: `unsigned long long` to match the Integer marshalling
1163
+ * (`ULL2NUM`) on Ruby's side; signed wraparound is undefined behaviour
1164
+ * and we want defined unsigned-rollover semantics for the audit
1165
+ * counter (which would only matter at ~10^19 total requests).
1166
+ */
1167
+ static volatile unsigned long long hyp_cl_requests_served_total = 0;
1168
+
1169
+ static inline void hyp_cl_tick_request(void) {
1170
+ __atomic_add_fetch(&hyp_cl_requests_served_total, 1ULL, __ATOMIC_RELAXED);
1171
+ }
1172
+
1173
+ static inline unsigned long long hyp_cl_load_requests_served(void) {
1174
+ return __atomic_load_n(&hyp_cl_requests_served_total, __ATOMIC_RELAXED);
1175
+ }
1176
+
1177
+ static inline void hyp_cl_reset_requests_served(void) {
1178
+ __atomic_store_n(&hyp_cl_requests_served_total, 0ULL, __ATOMIC_RELAXED);
1179
+ }
1180
+
1181
+ /* Header-section size cap. Anything bigger is rejected: the request
1182
+ * is malformed or hostile, and either way Ruby's full parser is
1183
+ * the right place to produce an error response. Mirrors
1184
+ * `Connection::MAX_HEADER_BYTES` (64 KiB). */
1185
+ #define HYP_CL_MAX_HEADER_BYTES 65536
1186
+ /* Read chunk for header accumulation. 8 KiB matches the Ruby-side
1187
+ * `INBUF_INITIAL_CAPACITY` so a typical request fits in one recv. */
1188
+ #define HYP_CL_READ_CHUNK 8192
1189
+
1190
+ /* ---- accept ---- */
1191
+ typedef struct {
1192
+ int listen_fd;
1193
+ int client_fd;
1194
+ int err;
1195
+ } hyp_cl_accept_args_t;
1196
+
1197
+ static void *hyp_cl_accept_blocking(void *raw) {
1198
+ hyp_cl_accept_args_t *a = (hyp_cl_accept_args_t *)raw;
1199
+ a->client_fd = -1;
1200
+ a->err = 0;
1201
+ for (;;) {
1202
+ struct sockaddr_storage ss;
1203
+ socklen_t slen = (socklen_t)sizeof(ss);
1204
+ int fd = accept(a->listen_fd, (struct sockaddr *)&ss, &slen);
1205
+ if (fd >= 0) {
1206
+ a->client_fd = fd;
1207
+ return NULL;
1208
+ }
1209
+ if (errno == EINTR) {
1210
+ continue;
1211
+ }
1212
+ if (errno == EAGAIN || errno == EWOULDBLOCK) {
1213
+ /* Listener fd was non-blocking despite our F_SETFL clear
1214
+ * (or someone else flipped it back). Park on select() so
1215
+ * we don't busy-loop. The stop flag is checked by the
1216
+ * outer Ruby caller between accepts. */
1217
+ fd_set rfds;
1218
+ FD_ZERO(&rfds);
1219
+ FD_SET(a->listen_fd, &rfds);
1220
+ struct timeval tv;
1221
+ tv.tv_sec = 1;
1222
+ tv.tv_usec = 0;
1223
+ int s = select(a->listen_fd + 1, &rfds, NULL, NULL, &tv);
1224
+ if (s < 0 && errno == EINTR) {
1225
+ continue;
1226
+ }
1227
+ /* On timeout, return so the caller can check the stop
1228
+ * flag and re-enter. */
1229
+ if (s == 0) {
1230
+ a->err = EAGAIN;
1231
+ return NULL;
1232
+ }
1233
+ continue;
1234
+ }
1235
+ a->err = errno;
1236
+ return NULL;
1237
+ }
1238
+ }
1239
+
1240
+ /* ---- recv ---- */
1241
+ typedef struct {
1242
+ int fd;
1243
+ char *buf;
1244
+ size_t cap;
1245
+ size_t off;
1246
+ int err;
1247
+ /* Set by the caller; signals that we should bail out of recv on
1248
+ * the first EAGAIN/EWOULDBLOCK rather than retrying. Used between
1249
+ * keep-alive requests so an idle conn doesn't stall the worker. */
1250
+ int nonblock_first;
1251
+ } hyp_cl_recv_args_t;
1252
+
1253
+ static void *hyp_cl_recv_blocking(void *raw) {
1254
+ hyp_cl_recv_args_t *a = (hyp_cl_recv_args_t *)raw;
1255
+ a->err = 0;
1256
+ for (;;) {
1257
+ if (a->off >= a->cap) {
1258
+ a->err = E2BIG;
1259
+ return NULL;
1260
+ }
1261
+ ssize_t n = recv(a->fd, a->buf + a->off, a->cap - a->off, 0);
1262
+ if (n > 0) {
1263
+ a->off += (size_t)n;
1264
+ /* Look for end-of-headers (\r\n\r\n). Bounded scan over
1265
+ * what we've buffered so far. */
1266
+ if (a->off >= 4) {
1267
+ /* Fast scan of the just-read window first; fall back to
1268
+ * a full scan if a CRLFCRLF straddled a recv boundary. */
1269
+ const char *base = a->buf;
1270
+ size_t scan_end = a->off;
1271
+ size_t i = (a->off - (size_t)n >= 3) ? a->off - (size_t)n - 3 : 0;
1272
+ while (i + 3 < scan_end) {
1273
+ if (base[i] == '\r' && base[i+1] == '\n' &&
1274
+ base[i+2] == '\r' && base[i+3] == '\n') {
1275
+ return NULL;
1276
+ }
1277
+ i++;
1278
+ }
1279
+ }
1280
+ continue;
1281
+ }
1282
+ if (n == 0) {
1283
+ /* Peer closed cleanly. */
1284
+ a->err = ECONNRESET;
1285
+ return NULL;
1286
+ }
1287
+ if (errno == EINTR) {
1288
+ continue;
1289
+ }
1290
+ if (errno == EAGAIN || errno == EWOULDBLOCK) {
1291
+ /* The fd was unexpectedly non-blocking (Darwin's accept(2)
1292
+ * doesn't propagate O_NONBLOCK from the listener, but the
1293
+ * defensive branch keeps us correct on hosts where it
1294
+ * does — or where someone flipped the flag on the
1295
+ * accepted socket via setsockopt). Park on select(). */
1296
+ fd_set rfds;
1297
+ FD_ZERO(&rfds);
1298
+ FD_SET(a->fd, &rfds);
1299
+ struct timeval tv;
1300
+ tv.tv_sec = 30; /* generous; matches Connection's read timeout */
1301
+ tv.tv_usec = 0;
1302
+ int s = select(a->fd + 1, &rfds, NULL, NULL, &tv);
1303
+ if (s < 0 && errno == EINTR) {
1304
+ continue;
1305
+ }
1306
+ if (s == 0) {
1307
+ a->err = ETIMEDOUT;
1308
+ return NULL;
1309
+ }
1310
+ continue;
1311
+ }
1312
+ a->err = errno;
1313
+ return NULL;
1314
+ }
1315
+ }
1316
+
1317
+ /* Find end-of-headers in `buf` of length `len`. Returns the byte
1318
+ * offset PAST the trailing CRLFCRLF (i.e. where the body, if any,
1319
+ * would start), or -1 if not found. */
1320
+ static long hyp_cl_find_eoh(const char *buf, size_t len) {
1321
+ if (len < 4) {
1322
+ return -1;
1323
+ }
1324
+ for (size_t i = 0; i + 3 < len; i++) {
1325
+ if (buf[i] == '\r' && buf[i+1] == '\n' &&
1326
+ buf[i+2] == '\r' && buf[i+3] == '\n') {
1327
+ return (long)(i + 4);
1328
+ }
1329
+ }
1330
+ return -1;
1331
+ }
1332
+
1333
+ /* Parse the request line out of the headers section. On success,
1334
+ * fills *m_off, *m_len, *p_off, *p_len with the offsets/lengths of
1335
+ * METHOD and PATH inside `buf`, and returns the length of the
1336
+ * request line including the trailing CRLF. On malformed input
1337
+ * returns -1. The version (HTTP/1.1) is checked here too — anything
1338
+ * other than HTTP/1.1 returns -1 so the caller hands off to Ruby. */
1339
+ static long hyp_cl_parse_request_line(const char *buf, size_t len,
1340
+ size_t *m_off, size_t *m_len,
1341
+ size_t *p_off, size_t *p_len) {
1342
+ /* Find first SP — separates METHOD from PATH. */
1343
+ size_t i = 0;
1344
+ while (i < len && buf[i] != ' ' && buf[i] != '\r' && buf[i] != '\n') {
1345
+ i++;
1346
+ }
1347
+ if (i == 0 || i >= len || buf[i] != ' ') {
1348
+ return -1;
1349
+ }
1350
+ *m_off = 0;
1351
+ *m_len = i;
1352
+ i++;
1353
+ size_t p_start = i;
1354
+ while (i < len && buf[i] != ' ' && buf[i] != '\r' && buf[i] != '\n') {
1355
+ i++;
1356
+ }
1357
+ if (i >= len || buf[i] != ' ' || i == p_start) {
1358
+ return -1;
1359
+ }
1360
+ *p_off = p_start;
1361
+ *p_len = i - p_start;
1362
+ i++;
1363
+ /* Version: must be exactly "HTTP/1.1" followed by CRLF for the
1364
+ * C path. HTTP/1.0 has different keep-alive defaults; let Ruby
1365
+ * handle it. */
1366
+ if (i + 10 > len) {
1367
+ return -1;
1368
+ }
1369
+ if (memcmp(buf + i, "HTTP/1.1\r\n", 10) != 0) {
1370
+ return -1;
1371
+ }
1372
+ return (long)(i + 10);
1373
+ }
1374
+
1375
+ /* Case-insensitive byte compare for header names. */
1376
+ static int hyp_cl_iequals(const char *a, size_t alen, const char *b, size_t blen) {
1377
+ if (alen != blen) {
1378
+ return 0;
1379
+ }
1380
+ for (size_t i = 0; i < alen; i++) {
1381
+ char ca = a[i];
1382
+ char cb = b[i];
1383
+ if (ca >= 'A' && ca <= 'Z') ca = (char)(ca + 32);
1384
+ if (cb >= 'A' && cb <= 'Z') cb = (char)(cb + 32);
1385
+ if (ca != cb) return 0;
1386
+ }
1387
+ return 1;
1388
+ }
1389
+
1390
+ /* Inspect the header block (between request-line end and CRLFCRLF)
1391
+ * and report:
1392
+ * *connection_close: 1 if Connection: close was seen, 0 otherwise.
1393
+ * *has_body: 1 if Content-Length>0 or Transfer-Encoding
1394
+ * was seen (anything but CL:0).
1395
+ * *upgrade_seen: 1 if Upgrade or h2 settings header was seen.
1396
+ *
1397
+ * Returns 0 on success, -1 on malformed framing. */
1398
+ static int hyp_cl_scan_headers(const char *buf, size_t start, size_t end,
1399
+ int *connection_close, int *has_body,
1400
+ int *upgrade_seen) {
1401
+ *connection_close = 0;
1402
+ *has_body = 0;
1403
+ *upgrade_seen = 0;
1404
+ /* `end` points just past the closing CRLFCRLF; the last meaningful
1405
+ * header byte is at `end - 5` (the CR of the final header's CRLF
1406
+ * followed by the empty CRLF). The terminator we look for is
1407
+ * `\r\n` at positions [end-4, end-3]. */
1408
+ size_t i = start;
1409
+ while (i + 2 <= end) {
1410
+ if (i + 1 < end && buf[i] == '\r' && buf[i+1] == '\n') {
1411
+ /* Empty line — end of headers reached. */
1412
+ return 0;
1413
+ }
1414
+ size_t name_start = i;
1415
+ while (i < end && buf[i] != ':' && buf[i] != '\r') {
1416
+ i++;
1417
+ }
1418
+ if (i >= end || buf[i] != ':') {
1419
+ return -1;
1420
+ }
1421
+ size_t name_end = i;
1422
+ i++; /* past ':' */
1423
+ while (i < end && (buf[i] == ' ' || buf[i] == '\t')) {
1424
+ i++;
1425
+ }
1426
+ size_t val_start = i;
1427
+ while (i < end && buf[i] != '\r') {
1428
+ i++;
1429
+ }
1430
+ if (i + 1 >= end || buf[i+1] != '\n') {
1431
+ return -1;
1432
+ }
1433
+ size_t val_end = i;
1434
+ i += 2; /* past CRLF */
1435
+
1436
+ size_t nlen = name_end - name_start;
1437
+ size_t vlen = val_end - val_start;
1438
+ const char *nptr = buf + name_start;
1439
+ const char *vptr = buf + val_start;
1440
+
1441
+ if (hyp_cl_iequals(nptr, nlen, "connection", 10)) {
1442
+ /* Trim trailing whitespace. */
1443
+ while (vlen > 0 && (vptr[vlen - 1] == ' ' || vptr[vlen - 1] == '\t')) {
1444
+ vlen--;
1445
+ }
1446
+ if (hyp_cl_iequals(vptr, vlen, "close", 5)) {
1447
+ *connection_close = 1;
1448
+ } else if (hyp_cl_iequals(vptr, vlen, "upgrade", 7)) {
1449
+ *upgrade_seen = 1;
1450
+ }
1451
+ } else if (hyp_cl_iequals(nptr, nlen, "content-length", 14)) {
1452
+ /* Trim leading/trailing whitespace then parse. */
1453
+ while (vlen > 0 && (vptr[0] == ' ' || vptr[0] == '\t')) {
1454
+ vptr++; vlen--;
1455
+ }
1456
+ while (vlen > 0 && (vptr[vlen - 1] == ' ' || vptr[vlen - 1] == '\t')) {
1457
+ vlen--;
1458
+ }
1459
+ int cl_zero = (vlen == 1 && vptr[0] == '0');
1460
+ if (!cl_zero) {
1461
+ *has_body = 1;
1462
+ }
1463
+ } else if (hyp_cl_iequals(nptr, nlen, "transfer-encoding", 17)) {
1464
+ *has_body = 1;
1465
+ } else if (hyp_cl_iequals(nptr, nlen, "upgrade", 7)) {
1466
+ *upgrade_seen = 1;
1467
+ } else if (hyp_cl_iequals(nptr, nlen, "http2-settings", 14)) {
1468
+ *upgrade_seen = 1;
1469
+ }
1470
+ }
1471
+ return -1;
1472
+ }
1473
+
1474
+ /* Lifecycle fire helper: rb_funcall into the registered callback
1475
+ * with (method_str, path_str). Wrapped in rb_protect so a misbehaving
1476
+ * Ruby hook can't take down the C loop. */
1477
+ typedef struct {
1478
+ VALUE callback;
1479
+ VALUE method_str;
1480
+ VALUE path_str;
1481
+ } hyp_cl_hook_args_t;
1482
+
1483
+ static VALUE hyp_cl_hook_invoke(VALUE raw) {
1484
+ hyp_cl_hook_args_t *a = (hyp_cl_hook_args_t *)raw;
1485
+ return rb_funcall(a->callback, rb_intern("call"), 2,
1486
+ a->method_str, a->path_str);
1487
+ }
1488
+
1489
+ static void hyp_cl_fire_lifecycle(const char *method, size_t mlen,
1490
+ const char *path, size_t plen) {
1491
+ if (!hyp_cl_lifecycle_active || NIL_P(hyp_cl_lifecycle_callback)) {
1492
+ return;
1493
+ }
1494
+ hyp_cl_hook_args_t a;
1495
+ a.callback = hyp_cl_lifecycle_callback;
1496
+ a.method_str = rb_str_new(method, (long)mlen);
1497
+ a.path_str = rb_str_new(path, (long)plen);
1498
+ int state = 0;
1499
+ rb_protect(hyp_cl_hook_invoke, (VALUE)&a, &state);
1500
+ /* Swallow the exception state — same contract as `Runtime#fire_*`:
1501
+ * a misbehaving observer must not break dispatch. The Ruby-side
1502
+ * callback already wraps individual hooks in their own rescues
1503
+ * and logs failures; this protect is belt-and-suspenders so the
1504
+ * C loop can't crash on a hook error either. */
1505
+ if (state) {
1506
+ rb_set_errinfo(Qnil);
1507
+ }
1508
+ }
1509
+
1510
+ /* Handoff: invoke the Ruby callback with (fd, partial_buffer_str_or_nil).
1511
+ * Ruby owns the fd from that point on — C must not close it. */
1512
+ typedef struct {
1513
+ VALUE callback;
1514
+ VALUE fd_int;
1515
+ VALUE buffer_str;
1516
+ } hyp_cl_handoff_args_t;
1517
+
1518
+ static VALUE hyp_cl_handoff_invoke(VALUE raw) {
1519
+ hyp_cl_handoff_args_t *a = (hyp_cl_handoff_args_t *)raw;
1520
+ return rb_funcall(a->callback, rb_intern("call"), 2,
1521
+ a->fd_int, a->buffer_str);
1522
+ }
1523
+
1524
+ static void hyp_cl_handoff(int client_fd, const char *partial, size_t partial_len) {
1525
+ if (NIL_P(hyp_cl_handoff_callback)) {
1526
+ /* No callback registered — close the fd ourselves rather than
1527
+ * leaking it. This branch is paranoia; the Ruby side always
1528
+ * registers a handoff callback before starting the loop. */
1529
+ close(client_fd);
1530
+ return;
1531
+ }
1532
+ hyp_cl_handoff_args_t a;
1533
+ a.callback = hyp_cl_handoff_callback;
1534
+ a.fd_int = INT2NUM(client_fd);
1535
+ a.buffer_str = (partial_len > 0) ? rb_str_new(partial, (long)partial_len) : Qnil;
1536
+ int state = 0;
1537
+ rb_protect(hyp_cl_handoff_invoke, (VALUE)&a, &state);
1538
+ if (state) {
1539
+ /* Handoff failed — swallow and close the fd; better to drop
1540
+ * one connection than crash the whole loop. */
1541
+ rb_set_errinfo(Qnil);
1542
+ close(client_fd);
1543
+ }
1544
+ }
1545
+
1546
+ /* Apply TCP_NODELAY to the accepted connection. Mirrors 2.10-G — Nagle
1547
+ * off so small responses aren't held by the peer's delayed-ACK timer.
1548
+ * Best-effort; failures are swallowed (some socket types don't honour
1549
+ * the option, or it was already set). */
1550
+ static void hyp_cl_apply_tcp_nodelay(int fd) {
1551
+ int one = 1;
1552
+ (void)setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));
1553
+ }
1554
+
1555
+ /* Pre-built `404 Not Found` response, written to the socket when the
1556
+ * C loop sees a method/path it can answer with a definite negative
1557
+ * BUT can't hand off to Ruby (because the rest of the request looks
1558
+ * like a normal static request — peer expects an HTTP response, not
1559
+ * a TCP RST). Used only when handoff_callback is nil; otherwise we
1560
+ * always prefer to hand off to let Ruby produce the response.
1561
+ *
1562
+ * Currently unused — kept for future "C-only mode" where Ruby is
1563
+ * never re-entered. */
1564
+ /* static const char hyp_cl_404[] =
1565
+ * "HTTP/1.1 404 Not Found\r\n"
1566
+ * "content-type: text/plain\r\n"
1567
+ * "content-length: 9\r\n"
1568
+ * "connection: close\r\n"
1569
+ * "\r\n"
1570
+ * "not found"; */
1571
+
1572
+ /* Serve one connection on `client_fd`. Returns the count of requests
1573
+ * served on this connection; -1 if the connection ended in a way
1574
+ * that should not increment the served counter (handoff, peer
1575
+ * disconnect mid-request). The `*handed_off` flag distinguishes
1576
+ * "Ruby took ownership of this fd" (we must NOT close it) from
1577
+ * "peer closed and we should close" (we close locally).
1578
+ *
1579
+ * The full headers buffer is kept stack-local — at 8 KiB it fits
1580
+ * comfortably and we avoid per-connection malloc traffic. */
1581
+ static long hyp_cl_serve_connection(int client_fd, int *handed_off) {
1582
+ *handed_off = 0;
1583
+ long served = 0;
1584
+ char buf[HYP_CL_MAX_HEADER_BYTES];
1585
+ size_t buf_len = 0;
1586
+
1587
+ /* Apply TCP_NODELAY once per connection — it sticks for the
1588
+ * lifetime of the fd. */
1589
+ hyp_cl_apply_tcp_nodelay(client_fd);
1590
+
1591
+ for (;;) {
1592
+ if (hyp_cl_stop) {
1593
+ close(client_fd);
1594
+ return served;
1595
+ }
1596
+
1597
+ /* If we have leftover bytes from the previous request (pipelined
1598
+ * input), they're already at the start of `buf`. Read more
1599
+ * until we have a full header section. */
1600
+ long eoh = hyp_cl_find_eoh(buf, buf_len);
1601
+ while (eoh < 0) {
1602
+ hyp_cl_recv_args_t r;
1603
+ r.fd = client_fd;
1604
+ r.buf = buf;
1605
+ r.cap = sizeof(buf);
1606
+ r.off = buf_len;
1607
+ r.err = 0;
1608
+ r.nonblock_first = 0;
1609
+ rb_thread_call_without_gvl(hyp_cl_recv_blocking, &r,
1610
+ RUBY_UBF_IO, NULL);
1611
+ buf_len = r.off;
1612
+ if (r.err == ECONNRESET || r.err == ECONNABORTED) {
1613
+ /* Peer hung up. Close locally; not a request. */
1614
+ close(client_fd);
1615
+ return served;
1616
+ }
1617
+ if (r.err != 0 && r.err != EINTR) {
1618
+ /* Unexpected read error — close locally. */
1619
+ close(client_fd);
1620
+ return served;
1621
+ }
1622
+ eoh = hyp_cl_find_eoh(buf, buf_len);
1623
+ if (eoh < 0 && buf_len >= sizeof(buf)) {
1624
+ /* Header section exceeds our cap — hand off to Ruby
1625
+ * (its parser produces a 400 with the right shape). */
1626
+ hyp_cl_handoff(client_fd, buf, buf_len);
1627
+ *handed_off = 1;
1628
+ return served;
1629
+ }
1630
+ }
1631
+
1632
+ size_t method_off, method_len, path_off, path_len;
1633
+ long req_line_end = hyp_cl_parse_request_line(
1634
+ buf, (size_t)eoh, &method_off, &method_len, &path_off, &path_len);
1635
+ if (req_line_end < 0) {
1636
+ /* Malformed or HTTP/1.0 — let Ruby handle the response. */
1637
+ hyp_cl_handoff(client_fd, buf, buf_len);
1638
+ *handed_off = 1;
1639
+ return served;
1640
+ }
1641
+
1642
+ int connection_close = 0;
1643
+ int has_body = 0;
1644
+ int upgrade_seen = 0;
1645
+ int hdr_ok = hyp_cl_scan_headers(buf, (size_t)req_line_end, (size_t)eoh,
1646
+ &connection_close, &has_body, &upgrade_seen);
1647
+ if (hdr_ok != 0 || has_body || upgrade_seen) {
1648
+ hyp_cl_handoff(client_fd, buf, buf_len);
1649
+ *handed_off = 1;
1650
+ return served;
1651
+ }
1652
+
1653
+ /* Method + path lookup against the page cache. Reuse the
1654
+ * existing classify + lookup helpers so the C-side cache state
1655
+ * is the single source of truth — `Server.handle_static`
1656
+ * registers entries via `register_prebuilt`, this loop reads
1657
+ * them via `lookup_locked`. */
1658
+ hyp_pc_method_t kind = hyp_pc_classify_method(buf + method_off, method_len);
1659
+ if (kind == HYP_PC_METHOD_OTHER) {
1660
+ hyp_cl_handoff(client_fd, buf, buf_len);
1661
+ *handed_off = 1;
1662
+ return served;
1663
+ }
1664
+
1665
+ pthread_mutex_lock(&hyp_pc_lock);
1666
+ int was_stale = 0;
1667
+ hyp_page_slot_t *slot = hyp_pc_lookup_locked(buf + path_off, path_len, &was_stale);
1668
+ if (slot == NULL) {
1669
+ pthread_mutex_unlock(&hyp_pc_lock);
1670
+ hyp_cl_handoff(client_fd, buf, buf_len);
1671
+ *handed_off = 1;
1672
+ return served;
1673
+ }
1674
+ size_t write_len = (kind == HYP_PC_METHOD_HEAD)
1675
+ ? slot->page->headers_len
1676
+ : slot->page->response_len;
1677
+ char *snapshot = (char *)malloc(write_len);
1678
+ if (snapshot == NULL) {
1679
+ pthread_mutex_unlock(&hyp_pc_lock);
1680
+ /* OOM mid-loop — hand off so Ruby can return 500. */
1681
+ hyp_cl_handoff(client_fd, buf, buf_len);
1682
+ *handed_off = 1;
1683
+ return served;
1684
+ }
1685
+ memcpy(snapshot, slot->page->response_buf, write_len);
1686
+ pthread_mutex_unlock(&hyp_pc_lock);
1687
+
1688
+ hyp_pc_write_args_t wargs;
1689
+ wargs.fd = client_fd;
1690
+ wargs.buf = snapshot;
1691
+ wargs.len = write_len;
1692
+ wargs.total = 0;
1693
+ wargs.err = 0;
1694
+ rb_thread_call_without_gvl(hyp_pc_write_blocking, &wargs,
1695
+ RUBY_UBF_IO, NULL);
1696
+ free(snapshot);
1697
+
1698
+ if (wargs.err != 0 && wargs.total == 0) {
1699
+ /* Write failed — peer most likely gone. Close and exit. */
1700
+ close(client_fd);
1701
+ return served;
1702
+ }
1703
+
1704
+ served++;
1705
+ /* 2.12-E — per-process tick. Lock-free atomic so the SO_REUSEPORT
1706
+ * audit harness can scrape `c_loop_requests_total` mid-bench
1707
+ * without serialising on a Ruby-side mutex. */
1708
+ hyp_cl_tick_request();
1709
+
1710
+ /* Lifecycle hooks fire AFTER the wire write so observers see a
1711
+ * completed request. Keep this off the no-hook hot path via
1712
+ * the integer flag. */
1713
+ if (hyp_cl_lifecycle_active) {
1714
+ hyp_cl_fire_lifecycle(buf + method_off, method_len,
1715
+ buf + path_off, path_len);
1716
+ }
1717
+
1718
+ /* Carry pipelined bytes forward into the same buffer. */
1719
+ size_t consumed = (size_t)eoh;
1720
+ if (consumed < buf_len) {
1721
+ memmove(buf, buf + consumed, buf_len - consumed);
1722
+ buf_len -= consumed;
1723
+ } else {
1724
+ buf_len = 0;
1725
+ }
1726
+
1727
+ if (connection_close) {
1728
+ /* Half-close, then briefly drain any inbound bytes so the
1729
+ * close() doesn't trigger an RST. Some platforms (notably
1730
+ * macOS) deliver RST when close() is called on a socket
1731
+ * with unread bytes in the receive queue — even the peer's
1732
+ * normal FIN-empty packet can race the close and surface
1733
+ * as ECONNRESET to the peer's last read(2). The drain is
1734
+ * bounded to a small absolute deadline so a misbehaving
1735
+ * peer can't stall us. */
1736
+ shutdown(client_fd, SHUT_WR);
1737
+ char drain[1024];
1738
+ for (int i = 0; i < 4; i++) {
1739
+ ssize_t n = recv(client_fd, drain, sizeof(drain), MSG_DONTWAIT);
1740
+ if (n <= 0) break;
1741
+ }
1742
+ close(client_fd);
1743
+ return served;
1744
+ }
1745
+ /* Keep-alive: loop back and read the next request. */
1746
+ }
1747
+ }
1748
+
1749
+ /* Args passed into the no-GVL blocking accept wrapper. */
1750
+ typedef struct {
1751
+ int listen_fd;
1752
+ /* On return: the served-request count for this loop invocation,
1753
+ * or -1 if the listener returned EBADF (graceful close). */
1754
+ long served_count;
1755
+ } hyp_cl_loop_args_t;
1756
+
1757
+ /* PageCache.run_static_accept_loop(listen_fd) -> Integer | :crashed
1758
+ *
1759
+ * Drives the accept-and-serve loop. Returns the count of requests
1760
+ * served when the loop exits cleanly (listener closed, stop flag
1761
+ * raised) or `:crashed` if an unrecoverable accept error happened. */
1762
+ static VALUE rb_pc_run_static_accept_loop(VALUE self, VALUE rb_listen_fd) {
1763
+ (void)self;
1764
+ int listen_fd = NUM2INT(rb_listen_fd);
1765
+ if (listen_fd < 0) {
1766
+ rb_raise(rb_eArgError, "listen_fd must be >= 0");
1767
+ }
1768
+ hyp_cl_stop = 0;
1769
+ /* 2.12-E — reset the per-process served-request counter on entry
1770
+ * so the audit metric reflects THIS loop's served count (not
1771
+ * leftovers from a prior loop in the same process — primarily a
1772
+ * test-suite concern; production has at most one loop per process
1773
+ * lifetime). */
1774
+ hyp_cl_reset_requests_served();
1775
+
1776
+ /* Ruby's `TCPServer.new` sets O_NONBLOCK on the listening fd so
1777
+ * `IO.select` + `accept_nonblock` works naturally on the Ruby
1778
+ * side. Our C accept loop wants a BLOCKING fd: we release the
1779
+ * GVL during the accept syscall and want the kernel to park us
1780
+ * there rather than burning CPU on EAGAIN. Clear O_NONBLOCK
1781
+ * unconditionally — the operator's existing accept-loop code
1782
+ * paths don't share this fd with us (we own it for the lifetime
1783
+ * of `run_static_accept_loop`). */
1784
+ int flags = fcntl(listen_fd, F_GETFL, 0);
1785
+ if (flags >= 0 && (flags & O_NONBLOCK)) {
1786
+ (void)fcntl(listen_fd, F_SETFL, flags & ~O_NONBLOCK);
1787
+ }
1788
+
1789
+ long served = 0;
1790
+ for (;;) {
1791
+ if (hyp_cl_stop) {
1792
+ break;
1793
+ }
1794
+ hyp_cl_accept_args_t a;
1795
+ a.listen_fd = listen_fd;
1796
+ a.client_fd = -1;
1797
+ a.err = 0;
1798
+ rb_thread_call_without_gvl(hyp_cl_accept_blocking, &a,
1799
+ RUBY_UBF_IO, NULL);
1800
+ if (a.client_fd < 0) {
1801
+ if (a.err == EBADF || a.err == EINVAL) {
1802
+ /* Listener was closed — graceful exit. */
1803
+ break;
1804
+ }
1805
+ if (a.err == ECONNABORTED || a.err == EAGAIN ||
1806
+ a.err == EWOULDBLOCK || a.err == EINTR) {
1807
+ /* Transient — re-check stop flag and re-enter accept. */
1808
+ continue;
1809
+ }
1810
+ /* Unexpected accept error; surface as :crashed so Ruby can
1811
+ * fall back to its own accept loop. */
1812
+ return ID2SYM(rb_intern("crashed"));
1813
+ }
1814
+
1815
+ int handed_off = 0;
1816
+ long n = hyp_cl_serve_connection(a.client_fd, &handed_off);
1817
+ if (n > 0) {
1818
+ served += n;
1819
+ }
1820
+ /* hyp_cl_serve_connection closes the fd itself unless it handed
1821
+ * off to Ruby. Either way our work for this connection is
1822
+ * done. */
1823
+ }
1824
+ return LONG2NUM(served);
1825
+ }
1826
+
1827
+ /* PageCache.set_lifecycle_callback(callable_or_nil) -> callable_or_nil
1828
+ *
1829
+ * Registers (or clears) the per-request lifecycle callback. Called
1830
+ * once at server boot from `Hyperion::Server`'s accept-loop set-up.
1831
+ * The callback receives (method_str, path_str) once per request the
1832
+ * C loop served; the Ruby implementation builds a Request and fires
1833
+ * `Runtime#fire_request_start` + `fire_request_end`. */
1834
+ static VALUE rb_pc_set_lifecycle_callback(VALUE self, VALUE callback) {
1835
+ (void)self;
1836
+ if (!NIL_P(callback) && !rb_respond_to(callback, rb_intern("call"))) {
1837
+ rb_raise(rb_eArgError, "callback must respond to #call");
1838
+ }
1839
+ hyp_cl_lifecycle_callback = callback;
1840
+ return callback;
1841
+ }
1842
+
1843
+ /* PageCache.set_lifecycle_active(bool) -> bool
1844
+ *
1845
+ * Toggles the integer flag the C loop reads on every request to
1846
+ * decide whether to invoke the lifecycle callback. Decoupled from
1847
+ * the callback registration so Ruby can flip it cheaply when hooks
1848
+ * are added/removed at runtime, without re-registering the
1849
+ * callback object itself. */
1850
+ static VALUE rb_pc_set_lifecycle_active(VALUE self, VALUE flag) {
1851
+ (void)self;
1852
+ hyp_cl_lifecycle_active = RTEST(flag) ? 1 : 0;
1853
+ return flag;
1854
+ }
1855
+
1856
+ /* PageCache.lifecycle_active? -> bool
1857
+ *
1858
+ * Spec/operator helper. */
1859
+ static VALUE rb_pc_lifecycle_active_p(VALUE self) {
1860
+ (void)self;
1861
+ return hyp_cl_lifecycle_active ? Qtrue : Qfalse;
1862
+ }
1863
+
1864
+ /* PageCache.set_handoff_callback(callable) -> callable
1865
+ *
1866
+ * Registers the callback the C loop invokes when a request can't
1867
+ * be served from the static cache. Receives (fd_int, partial_buffer_str_or_nil)
1868
+ * — Ruby owns the fd from that point on. The accept loop continues
1869
+ * to the next connection; a handoff is per-connection, not per-
1870
+ * accept-loop. */
1871
+ static VALUE rb_pc_set_handoff_callback(VALUE self, VALUE callback) {
1872
+ (void)self;
1873
+ if (!NIL_P(callback) && !rb_respond_to(callback, rb_intern("call"))) {
1874
+ rb_raise(rb_eArgError, "callback must respond to #call");
1875
+ }
1876
+ hyp_cl_handoff_callback = callback;
1877
+ return callback;
1878
+ }
1879
+
1880
+ /* PageCache.stop_accept_loop -> nil
1881
+ *
1882
+ * Flips the stop flag. The accept loop checks it between accepts and
1883
+ * (more importantly) between keep-alive requests on the same
1884
+ * connection; the `Server#stop` close()-on-listener is the primary
1885
+ * signal (it races us out via accept returning EBADF). */
1886
+ static VALUE rb_pc_stop_accept_loop(VALUE self) {
1887
+ (void)self;
1888
+ hyp_cl_stop = 1;
1889
+ return Qnil;
1890
+ }
1891
+
1892
+ /* PageCache.c_loop_requests_total -> Integer
1893
+ *
1894
+ * 2.12-E — the running per-process count of requests served by either
1895
+ * the 2.12-C accept4 loop or the 2.12-D io_uring loop since the
1896
+ * loop-entry reset. Read at /-/metrics scrape time so the
1897
+ * `hyperion_requests_dispatch_total{worker_id=PID}` family reflects
1898
+ * C-loop-served requests in addition to Ruby-side ones. Lock-free
1899
+ * via the same atomic the loop bumps on each request. */
1900
+ static VALUE rb_pc_c_loop_requests_total(VALUE self) {
1901
+ (void)self;
1902
+ return ULL2NUM(hyp_cl_load_requests_served());
1903
+ }
1904
+
1905
+ /* PageCache.reset_c_loop_requests_total! -> 0
1906
+ *
1907
+ * 2.12-E — spec/operator escape hatch for clearing the per-process
1908
+ * counter between bench runs without restarting the worker. Production
1909
+ * has no need for this; the loop-entry path resets implicitly. */
1910
+ static VALUE rb_pc_reset_c_loop_requests_total_bang(VALUE self) {
1911
+ (void)self;
1912
+ hyp_cl_reset_requests_served();
1913
+ return INT2NUM(0);
1914
+ }
1915
+
1916
+ /* PageCache.bump_c_loop_requests_total_for_test!(n) -> Integer
1917
+ *
1918
+ * 2.12-E — spec-only counter primer. Lets the PrometheusExporter
1919
+ * fold-in test assert the merge logic without needing a live C loop
1920
+ * (which would tie the spec to listener bind + accept timing).
1921
+ * NOT documented as public surface; the name's `_for_test!` suffix
1922
+ * is the contract. */
1923
+ static VALUE rb_pc_bump_c_loop_requests_total_for_test_bang(VALUE self, VALUE rb_n) {
1924
+ (void)self;
1925
+ long n = NUM2LONG(rb_n);
1926
+ if (n < 0) {
1927
+ rb_raise(rb_eArgError, "n must be >= 0");
1928
+ }
1929
+ for (long i = 0; i < n; i++) {
1930
+ hyp_cl_tick_request();
1931
+ }
1932
+ return ULL2NUM(hyp_cl_load_requests_served());
1933
+ }
1934
+
1935
+ /* PageCache.handoff_to_ruby(client_fd, _partial_buffer, _partial_len) -> Integer
1936
+ *
1937
+ * Echo helper — exposed for spec parity with the bench-time API
1938
+ * shape called out in the 2.12-C plan. The actual handoff happens
1939
+ * inside the C loop via the registered callback; this method exists
1940
+ * so callers can introspect the contract without engaging the
1941
+ * accept loop. */
1942
+ static VALUE rb_pc_handoff_to_ruby(VALUE self, VALUE rb_fd, VALUE rb_buf,
1943
+ VALUE rb_len) {
1944
+ (void)self; (void)rb_buf; (void)rb_len;
1945
+ return rb_fd;
1946
+ }
1947
+
1064
1948
  /* PageCache.max_key_len -> Integer */
1065
1949
  static VALUE rb_pc_max_key_len(VALUE self) {
1066
1950
  (void)self;
1067
1951
  return INT2NUM(HYP_PC_MAX_KEY_LEN);
1068
1952
  }
1069
1953
 
1954
+ /* ============================================================
1955
+ * 2.12-D — sharing surface for io_uring_loop.c.
1956
+ *
1957
+ * Thin extern wrappers around the static helpers above. The io_uring
1958
+ * loop calls these once per request; the indirection cost is one
1959
+ * direct-call jump and is dominated by the syscall savings. Defined
1960
+ * here (rather than promoted-static) so the helpers' signatures stay
1961
+ * file-local and we don't accidentally widen the public surface of
1962
+ * the C ext. */
1963
+
1964
+ long pc_internal_find_eoh(const char *buf, size_t len) {
1965
+ return hyp_cl_find_eoh(buf, len);
1966
+ }
1967
+
1968
+ long pc_internal_parse_request_line(const char *buf, size_t len,
1969
+ size_t *m_off, size_t *m_len,
1970
+ size_t *p_off, size_t *p_len) {
1971
+ return hyp_cl_parse_request_line(buf, len, m_off, m_len, p_off, p_len);
1972
+ }
1973
+
1974
+ int pc_internal_scan_headers(const char *buf, size_t start, size_t end,
1975
+ int *connection_close, int *has_body,
1976
+ int *upgrade_seen) {
1977
+ return hyp_cl_scan_headers(buf, start, end, connection_close, has_body,
1978
+ upgrade_seen);
1979
+ }
1980
+
1981
+ pc_internal_method_t pc_internal_classify_method(const char *m, size_t len) {
1982
+ hyp_pc_method_t k = hyp_pc_classify_method(m, len);
1983
+ switch (k) {
1984
+ case HYP_PC_METHOD_GET: return PC_INTERNAL_METHOD_GET;
1985
+ case HYP_PC_METHOD_HEAD: return PC_INTERNAL_METHOD_HEAD;
1986
+ default: return PC_INTERNAL_METHOD_OTHER;
1987
+ }
1988
+ }
1989
+
1990
+ char *pc_internal_snapshot_response(const char *path, size_t path_len,
1991
+ pc_internal_method_t kind,
1992
+ size_t *out_len) {
1993
+ *out_len = 0;
1994
+ if (path_len == 0 || path_len > HYP_PC_MAX_KEY_LEN) {
1995
+ return NULL;
1996
+ }
1997
+ pthread_mutex_lock(&hyp_pc_lock);
1998
+ int was_stale = 0;
1999
+ hyp_page_slot_t *slot = hyp_pc_lookup_locked(path, path_len, &was_stale);
2000
+ if (slot == NULL) {
2001
+ pthread_mutex_unlock(&hyp_pc_lock);
2002
+ return NULL;
2003
+ }
2004
+ size_t write_len = (kind == PC_INTERNAL_METHOD_HEAD)
2005
+ ? slot->page->headers_len
2006
+ : slot->page->response_len;
2007
+ char *snapshot = (char *)malloc(write_len);
2008
+ if (snapshot == NULL) {
2009
+ pthread_mutex_unlock(&hyp_pc_lock);
2010
+ return NULL;
2011
+ }
2012
+ memcpy(snapshot, slot->page->response_buf, write_len);
2013
+ pthread_mutex_unlock(&hyp_pc_lock);
2014
+ *out_len = write_len;
2015
+ return snapshot;
2016
+ }
2017
+
2018
+ void pc_internal_apply_tcp_nodelay(int fd) {
2019
+ hyp_cl_apply_tcp_nodelay(fd);
2020
+ }
2021
+
2022
+ void pc_internal_fire_lifecycle(const char *method, size_t mlen,
2023
+ const char *path, size_t plen) {
2024
+ hyp_cl_fire_lifecycle(method, mlen, path, plen);
2025
+ }
2026
+
2027
+ int pc_internal_lifecycle_active(void) {
2028
+ return hyp_cl_lifecycle_active;
2029
+ }
2030
+
2031
+ void pc_internal_handoff(int client_fd, const char *partial, size_t partial_len) {
2032
+ hyp_cl_handoff(client_fd, partial, partial_len);
2033
+ }
2034
+
2035
+ int pc_internal_stop_requested(void) {
2036
+ return hyp_cl_stop ? 1 : 0;
2037
+ }
2038
+
2039
+ void pc_internal_reset_stop(void) {
2040
+ hyp_cl_stop = 0;
2041
+ }
2042
+
2043
+ /* 2.12-E — io_uring loop sibling tick / reset entry points. Forward to
2044
+ * the file-local helpers so the atomic stays a single-source-of-truth
2045
+ * for both loop variants. */
2046
+ void pc_internal_tick_request(void) {
2047
+ hyp_cl_tick_request();
2048
+ }
2049
+
2050
+ void pc_internal_reset_requests_served(void) {
2051
+ hyp_cl_reset_requests_served();
2052
+ }
2053
+
2054
+ /* Belt-and-suspenders: keep the io_uring sibling's view of the header
2055
+ * cap in sync with this file's. Compile-time check via array sizing
2056
+ * (we deliberately avoid C11 `_Static_assert` for portability with the
2057
+ * older toolchains some linux distros still ship). */
2058
+ typedef int pc_internal_header_cap_check_t
2059
+ [(HYP_CL_MAX_HEADER_BYTES == PC_INTERNAL_MAX_HEADER_BYTES) ? 1 : -1];
2060
+
1070
2061
  void Init_hyperion_page_cache(void) {
1071
2062
  rb_mHyperion_pc = rb_const_get(rb_cObject, rb_intern("Hyperion"));
1072
2063
 
@@ -1109,6 +2100,38 @@ void Init_hyperion_page_cache(void) {
1109
2100
  rb_pc_register_prebuilt, 3);
1110
2101
  rb_define_singleton_method(rb_mHyperionHttpPageCache, "serve_request",
1111
2102
  rb_pc_serve_request, 3);
2103
+ /* 2.12-C — connection lifecycle in C. */
2104
+ rb_define_singleton_method(rb_mHyperionHttpPageCache, "run_static_accept_loop",
2105
+ rb_pc_run_static_accept_loop, 1);
2106
+ rb_define_singleton_method(rb_mHyperionHttpPageCache, "stop_accept_loop",
2107
+ rb_pc_stop_accept_loop, 0);
2108
+ rb_define_singleton_method(rb_mHyperionHttpPageCache, "set_lifecycle_callback",
2109
+ rb_pc_set_lifecycle_callback, 1);
2110
+ rb_define_singleton_method(rb_mHyperionHttpPageCache, "set_lifecycle_active",
2111
+ rb_pc_set_lifecycle_active, 1);
2112
+ rb_define_singleton_method(rb_mHyperionHttpPageCache, "lifecycle_active?",
2113
+ rb_pc_lifecycle_active_p, 0);
2114
+ rb_define_singleton_method(rb_mHyperionHttpPageCache, "set_handoff_callback",
2115
+ rb_pc_set_handoff_callback, 1);
2116
+ rb_define_singleton_method(rb_mHyperionHttpPageCache, "handoff_to_ruby",
2117
+ rb_pc_handoff_to_ruby, 3);
2118
+
2119
+ /* 2.12-E — per-process served-request counter for the SO_REUSEPORT
2120
+ * load-balancing audit. Read at /-/metrics scrape time and folded
2121
+ * into `hyperion_requests_dispatch_total{worker_id=PID}` so
2122
+ * operators see a single per-worker number across every dispatch
2123
+ * shape (Rack via Connection, h2, the C loops). */
2124
+ rb_define_singleton_method(rb_mHyperionHttpPageCache, "c_loop_requests_total",
2125
+ rb_pc_c_loop_requests_total, 0);
2126
+ rb_define_singleton_method(rb_mHyperionHttpPageCache, "reset_c_loop_requests_total!",
2127
+ rb_pc_reset_c_loop_requests_total_bang, 0);
2128
+ rb_define_singleton_method(rb_mHyperionHttpPageCache, "bump_c_loop_requests_total_for_test!",
2129
+ rb_pc_bump_c_loop_requests_total_for_test_bang, 1);
2130
+
2131
+ /* Mark-protect the lifecycle / handoff callback slots so the GC
2132
+ * doesn't collect them while the C loop is running. */
2133
+ rb_gc_register_address(&hyp_cl_lifecycle_callback);
2134
+ rb_gc_register_address(&hyp_cl_handoff_callback);
1112
2135
 
1113
2136
  id_fileno_pc = rb_intern("fileno");
1114
2137
  id_to_io_pc = rb_intern("to_io");
@@ -1122,4 +2145,13 @@ void Init_hyperion_page_cache(void) {
1122
2145
  rb_gc_register_mark_object(sym_stale_pc);
1123
2146
  rb_gc_register_mark_object(sym_missing_pc);
1124
2147
  rb_gc_register_mark_object(sym_miss_pc);
2148
+
2149
+ /* 2.12-D — register the io_uring sibling. The init defines the
2150
+ * `run_static_io_uring_loop` Ruby method on the same module
2151
+ * (`Hyperion::Http::PageCache`) and lazy-initialises any per-process
2152
+ * io_uring state. On non-Linux / no-liburing builds the registered
2153
+ * method returns the `:unavailable` symbol so the Ruby caller can
2154
+ * fall through to the 2.12-C accept4 path. */
2155
+ extern void Init_hyperion_io_uring_loop(VALUE mPageCache);
2156
+ Init_hyperion_io_uring_loop(rb_mHyperionHttpPageCache);
1125
2157
  }