hyperion-rb 2.13.0 → 2.14.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.
@@ -1130,6 +1130,57 @@ static VALUE hyp_cl_lifecycle_callback = Qnil;
1130
1130
  static VALUE hyp_cl_handoff_callback = Qnil;
1131
1131
  static int hyp_cl_lifecycle_active = 0;
1132
1132
 
1133
+ /* 2.14-A — dynamic block registry. Path → Ruby Proc for routes
1134
+ * registered via `Server.handle(:GET, '/path') { |env| ... }`. The
1135
+ * C accept loop's `hyp_cl_serve_connection` checks this list AFTER
1136
+ * the static page cache lookup misses; on hit, it invokes
1137
+ * `hyp_dyn_dispatch_callback` (registered once at server boot) with
1138
+ * the request shape data + the registered block, gets back the
1139
+ * fully-formed HTTP/1.1 response bytes, and writes them outside the
1140
+ * GVL.
1141
+ *
1142
+ * The registry is small (16 entries default, capped at 256) — apps
1143
+ * with hundreds of dynamic routes belong on a Rails router, not on
1144
+ * the C accept loop's exact-match dispatch. The fixed-size array is
1145
+ * walked linearly because (a) modern CPUs blast through 256 cache
1146
+ * lines in tens of nanoseconds and (b) avoiding a hash table keeps
1147
+ * the C surface tiny. */
1148
+ #define HYP_DYN_MAX_ROUTES 256
1149
+
1150
+ typedef struct {
1151
+ char *path; /* heap-owned, NUL-terminated */
1152
+ size_t path_len;
1153
+ VALUE block; /* the Ruby Proc; gc-protected via the
1154
+ * registry-wide rb_gc_register_address slot
1155
+ * `hyp_dyn_routes_mark_anchor` (see below). */
1156
+ int method; /* PC_INTERNAL_METHOD_GET / HEAD; 2.14-A
1157
+ * accepts only GET (HEAD support is a future
1158
+ * extension — apps rarely need a custom HEAD
1159
+ * handler distinct from GET). */
1160
+ } hyp_dyn_route_t;
1161
+
1162
+ static hyp_dyn_route_t hyp_dyn_routes[HYP_DYN_MAX_ROUTES];
1163
+ static size_t hyp_dyn_route_count = 0;
1164
+ static pthread_mutex_t hyp_dyn_lock = PTHREAD_MUTEX_INITIALIZER;
1165
+
1166
+ /* The dispatch callback is the single bridge between the C loop and
1167
+ * Ruby for dynamic-block requests. Set once via
1168
+ * `set_dynamic_dispatch_callback`; cleared at server stop. The C loop
1169
+ * passes the registered block as the 7th positional arg so the
1170
+ * callback can call into the right Proc without re-doing the path
1171
+ * lookup on the Ruby side. */
1172
+ static VALUE hyp_dyn_dispatch_callback = Qnil;
1173
+
1174
+ /* 2.14-A — GC anchor for every Block VALUE we hold in the registry.
1175
+ * `rb_gc_register_address(&hyp_dyn_routes_mark_anchor)` keeps the
1176
+ * anchor alive; the registry's `mark` is performed via the
1177
+ * `hyp_dyn_routes_mark` GC mark function we wire into the
1178
+ * Hyperion::Http::PageCache module via `rb_define_module` plus a
1179
+ * small mark wrapper class. The simpler alternative — call
1180
+ * `rb_gc_register_address` once per registered block — works too;
1181
+ * we use that approach in `register_dynamic_block` below. */
1182
+ static VALUE hyp_dyn_routes_mark_anchor = Qnil;
1183
+
1133
1184
  /* Stop flag — flipped from Ruby via `stop_accept_loop` when the
1134
1185
  * listener should drop out of the loop voluntarily (graceful
1135
1186
  * shutdown). The accept syscall is still blocking; the operator's
@@ -1471,6 +1522,57 @@ static int hyp_cl_scan_headers(const char *buf, size_t start, size_t end,
1471
1522
  return -1;
1472
1523
  }
1473
1524
 
1525
+ /* 2.14-A — extract the `Host:` header value from a parsed header
1526
+ * block. Returns 0 on success and writes `*h_off`/`*h_len` (offsets
1527
+ * relative to `buf`); -1 if not found. The implementation mirrors
1528
+ * `hyp_cl_scan_headers` (case-insensitive name compare, trim
1529
+ * whitespace), but only matches Host. The C loop calls this only on
1530
+ * a dynamic-block hit — the static-fast-path doesn't read Host. */
1531
+ static int hyp_cl_extract_host(const char *buf, size_t start, size_t end,
1532
+ size_t *h_off, size_t *h_len) {
1533
+ *h_off = 0;
1534
+ *h_len = 0;
1535
+ size_t i = start;
1536
+ while (i + 2 <= end) {
1537
+ if (i + 1 < end && buf[i] == '\r' && buf[i+1] == '\n') {
1538
+ return -1; /* end of headers; not found */
1539
+ }
1540
+ size_t name_start = i;
1541
+ while (i < end && buf[i] != ':' && buf[i] != '\r') {
1542
+ i++;
1543
+ }
1544
+ if (i >= end || buf[i] != ':') {
1545
+ return -1;
1546
+ }
1547
+ size_t name_end = i;
1548
+ i++;
1549
+ while (i < end && (buf[i] == ' ' || buf[i] == '\t')) {
1550
+ i++;
1551
+ }
1552
+ size_t val_start = i;
1553
+ while (i < end && buf[i] != '\r') {
1554
+ i++;
1555
+ }
1556
+ if (i + 1 >= end || buf[i+1] != '\n') {
1557
+ return -1;
1558
+ }
1559
+ size_t val_end = i;
1560
+ i += 2;
1561
+
1562
+ size_t nlen = name_end - name_start;
1563
+ if (hyp_cl_iequals(buf + name_start, nlen, "host", 4)) {
1564
+ /* Trim trailing whitespace. */
1565
+ while (val_end > val_start && (buf[val_end - 1] == ' ' || buf[val_end - 1] == '\t')) {
1566
+ val_end--;
1567
+ }
1568
+ *h_off = val_start;
1569
+ *h_len = val_end - val_start;
1570
+ return 0;
1571
+ }
1572
+ }
1573
+ return -1;
1574
+ }
1575
+
1474
1576
  /* Lifecycle fire helper: rb_funcall into the registered callback
1475
1577
  * with (method_str, path_str). Wrapped in rb_protect so a misbehaving
1476
1578
  * Ruby hook can't take down the C loop. */
@@ -1552,6 +1654,140 @@ static void hyp_cl_apply_tcp_nodelay(int fd) {
1552
1654
  (void)setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));
1553
1655
  }
1554
1656
 
1657
+ /* ============================================================
1658
+ * 2.14-A — Dynamic block dispatch from inside the C accept loop.
1659
+ * ============================================================
1660
+ *
1661
+ * `hyp_dyn_lookup_block(path, plen, method)` walks the registered
1662
+ * dynamic-block table and returns the registered block VALUE on
1663
+ * exact-match hit, or `Qnil` on miss. Linear scan (capped at
1664
+ * HYP_DYN_MAX_ROUTES = 256 entries); the alternative — a hash map
1665
+ * inside C — would push more state into the C side without buying
1666
+ * meaningful throughput at the route counts the C accept loop
1667
+ * targets.
1668
+ *
1669
+ * The lookup grabs a lightweight pthread mutex; the registry is
1670
+ * almost always read-only after server boot, so contention is
1671
+ * negligible (one cmpxchg-style instruction per request). */
1672
+ static VALUE hyp_dyn_lookup_block(const char *path, size_t plen,
1673
+ hyp_pc_method_t method) {
1674
+ if (plen == 0 || method == HYP_PC_METHOD_OTHER) {
1675
+ return Qnil;
1676
+ }
1677
+ pthread_mutex_lock(&hyp_dyn_lock);
1678
+ VALUE found = Qnil;
1679
+ for (size_t i = 0; i < hyp_dyn_route_count; i++) {
1680
+ hyp_dyn_route_t *r = &hyp_dyn_routes[i];
1681
+ if (r->path_len == plen && memcmp(r->path, path, plen) == 0) {
1682
+ /* GET-registered entries also serve HEAD (the dispatcher
1683
+ * could trim the body; for 2.14-A we just call the block
1684
+ * and let it return whatever it returns — operators that
1685
+ * want a HEAD-specific shape register a separate route). */
1686
+ if ((int)method == r->method ||
1687
+ ((int)method == PC_INTERNAL_METHOD_HEAD && r->method == PC_INTERNAL_METHOD_GET)) {
1688
+ found = r->block;
1689
+ break;
1690
+ }
1691
+ }
1692
+ }
1693
+ pthread_mutex_unlock(&hyp_dyn_lock);
1694
+ return found;
1695
+ }
1696
+
1697
+ /* Args for the rb_protect-wrapped Ruby dispatch call. The 8-arg
1698
+ * Ruby callable is invoked under the GVL (we re-acquire it via
1699
+ * rb_thread_call_with_gvl from the no-GVL outer frame); the result
1700
+ * is a single binary String — the fully-formed HTTP/1.1 response. */
1701
+ typedef struct {
1702
+ VALUE callback;
1703
+ VALUE method_str;
1704
+ VALUE path_str;
1705
+ VALUE query_str;
1706
+ VALUE host_str;
1707
+ VALUE headers_blob;
1708
+ VALUE remote_addr;
1709
+ VALUE block;
1710
+ VALUE keep_alive;
1711
+ } hyp_dyn_dispatch_args_t;
1712
+
1713
+ static VALUE hyp_dyn_dispatch_invoke(VALUE raw) {
1714
+ hyp_dyn_dispatch_args_t *a = (hyp_dyn_dispatch_args_t *)raw;
1715
+ VALUE argv[8];
1716
+ argv[0] = a->method_str;
1717
+ argv[1] = a->path_str;
1718
+ argv[2] = a->query_str;
1719
+ argv[3] = a->host_str;
1720
+ argv[4] = a->headers_blob;
1721
+ argv[5] = a->remote_addr;
1722
+ argv[6] = a->block;
1723
+ argv[7] = a->keep_alive;
1724
+ return rb_funcallv(a->callback, rb_intern("call"), 8, argv);
1725
+ }
1726
+
1727
+ /* Drive one dynamic-block request through the registered Ruby
1728
+ * dispatch helper. On success, returns a freshly malloc'd byte
1729
+ * buffer containing the full HTTP/1.1 response (caller must free()
1730
+ * it) and writes the byte length into *out_len. On failure (no
1731
+ * callback, exception, return-shape error), returns NULL — the
1732
+ * caller will hand off to Ruby so the connection still gets a
1733
+ * response.
1734
+ *
1735
+ * MUST be called under the GVL. */
1736
+ static char *hyp_dyn_dispatch_under_gvl(const char *method, size_t mlen,
1737
+ const char *path, size_t plen,
1738
+ const char *query, size_t qlen,
1739
+ const char *host, size_t hlen,
1740
+ const char *hdrs, size_t hdrs_len,
1741
+ const char *peer, size_t peer_len,
1742
+ VALUE block, int keep_alive,
1743
+ size_t *out_len) {
1744
+ *out_len = 0;
1745
+ if (NIL_P(hyp_dyn_dispatch_callback) || NIL_P(block)) {
1746
+ return NULL;
1747
+ }
1748
+
1749
+ hyp_dyn_dispatch_args_t a;
1750
+ a.callback = hyp_dyn_dispatch_callback;
1751
+ a.method_str = rb_str_new(method, (long)mlen);
1752
+ a.path_str = rb_str_new(path, (long)plen);
1753
+ a.query_str = (qlen > 0) ? rb_str_new(query, (long)qlen) : rb_str_new("", 0);
1754
+ a.host_str = (hlen > 0) ? rb_str_new(host, (long)hlen) : rb_str_new("", 0);
1755
+ a.headers_blob = (hdrs_len > 0) ? rb_str_new(hdrs, (long)hdrs_len) : rb_str_new("", 0);
1756
+ a.remote_addr = (peer_len > 0) ? rb_str_new(peer, (long)peer_len) : rb_str_new("", 0);
1757
+ a.block = block;
1758
+ a.keep_alive = keep_alive ? Qtrue : Qfalse;
1759
+
1760
+ int state = 0;
1761
+ VALUE result = rb_protect(hyp_dyn_dispatch_invoke, (VALUE)&a, &state);
1762
+ if (state) {
1763
+ rb_set_errinfo(Qnil);
1764
+ return NULL;
1765
+ }
1766
+ if (!RB_TYPE_P(result, T_STRING)) {
1767
+ return NULL;
1768
+ }
1769
+ long len = RSTRING_LEN(result);
1770
+ if (len <= 0) {
1771
+ return NULL;
1772
+ }
1773
+ char *snapshot = (char *)malloc((size_t)len);
1774
+ if (snapshot == NULL) {
1775
+ return NULL;
1776
+ }
1777
+ memcpy(snapshot, RSTRING_PTR(result), (size_t)len);
1778
+ *out_len = (size_t)len;
1779
+ return snapshot;
1780
+ }
1781
+
1782
+ /* The accept-loop thread holds the GVL by default; only releases it
1783
+ * around the blocking syscalls (accept, recv, write). The dynamic
1784
+ * dispatch sits between the recv (which restored the GVL on return)
1785
+ * and the write (which drops it again), so no rb_thread_call_with_gvl
1786
+ * round-trip is needed — `hyp_dyn_dispatch_under_gvl` is called with
1787
+ * the GVL already held. The `_under_gvl` suffix is documentation:
1788
+ * the function MUST NOT be called from a `rb_thread_call_without_gvl`
1789
+ * frame (no Ruby VM access without the GVL). */
1790
+
1555
1791
  /* Pre-built `404 Not Found` response, written to the socket when the
1556
1792
  * C loop sees a method/path it can answer with a definite negative
1557
1793
  * BUT can't hand off to Ruby (because the rest of the request looks
@@ -1662,57 +1898,166 @@ static long hyp_cl_serve_connection(int client_fd, int *handed_off) {
1662
1898
  return served;
1663
1899
  }
1664
1900
 
1901
+ /* 2.14-A — split request-target into path + query. The path
1902
+ * registry uses the path-only key so an entry registered as
1903
+ * `/echo` matches `/echo?x=1`. */
1904
+ size_t path_only_len = path_len;
1905
+ size_t query_off = 0;
1906
+ size_t query_len = 0;
1907
+ for (size_t qi = 0; qi < path_len; qi++) {
1908
+ if (buf[path_off + qi] == '?') {
1909
+ path_only_len = qi;
1910
+ query_off = path_off + qi + 1;
1911
+ query_len = path_len - qi - 1;
1912
+ break;
1913
+ }
1914
+ }
1915
+
1665
1916
  pthread_mutex_lock(&hyp_pc_lock);
1666
1917
  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) {
1918
+ hyp_page_slot_t *slot = hyp_pc_lookup_locked(buf + path_off, path_only_len, &was_stale);
1919
+ if (slot != NULL) {
1920
+ size_t write_len = (kind == HYP_PC_METHOD_HEAD)
1921
+ ? slot->page->headers_len
1922
+ : slot->page->response_len;
1923
+ char *snapshot = (char *)malloc(write_len);
1924
+ if (snapshot == NULL) {
1925
+ pthread_mutex_unlock(&hyp_pc_lock);
1926
+ /* OOM mid-loop — hand off so Ruby can return 500. */
1927
+ hyp_cl_handoff(client_fd, buf, buf_len);
1928
+ *handed_off = 1;
1929
+ return served;
1930
+ }
1931
+ memcpy(snapshot, slot->page->response_buf, write_len);
1669
1932
  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) {
1933
+
1934
+ hyp_pc_write_args_t wargs;
1935
+ wargs.fd = client_fd;
1936
+ wargs.buf = snapshot;
1937
+ wargs.len = write_len;
1938
+ wargs.total = 0;
1939
+ wargs.err = 0;
1940
+ rb_thread_call_without_gvl(hyp_pc_write_blocking, &wargs,
1941
+ RUBY_UBF_IO, NULL);
1942
+ free(snapshot);
1943
+
1944
+ if (wargs.err != 0 && wargs.total == 0) {
1945
+ /* Write failed — peer most likely gone. Close and exit. */
1946
+ close(client_fd);
1947
+ return served;
1948
+ }
1949
+
1950
+ served++;
1951
+ /* 2.12-E — per-process tick. Lock-free atomic so the SO_REUSEPORT
1952
+ * audit harness can scrape `c_loop_requests_total` mid-bench
1953
+ * without serialising on a Ruby-side mutex. */
1954
+ hyp_cl_tick_request();
1955
+
1956
+ /* Lifecycle hooks fire AFTER the wire write so observers see a
1957
+ * completed request. Keep this off the no-hook hot path via
1958
+ * the integer flag. */
1959
+ if (hyp_cl_lifecycle_active) {
1960
+ hyp_cl_fire_lifecycle(buf + method_off, method_len,
1961
+ buf + path_off, path_only_len);
1962
+ }
1963
+ } else {
1679
1964
  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
1965
 
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);
1966
+ /* 2.14-A — try the dynamic-block registry. On hit, dispatch
1967
+ * through the registered Ruby callback under the GVL (we
1968
+ * already hold it — the recv() returned and we haven't yet
1969
+ * released for write). The callback returns a fully-formed
1970
+ * HTTP/1.1 response String; we copy to a heap buffer, then
1971
+ * release the GVL for the write so the next accept can
1972
+ * proceed concurrently on another thread. */
1973
+ VALUE block = hyp_dyn_lookup_block(buf + path_off, path_only_len, kind);
1974
+ if (NIL_P(block)) {
1975
+ /* No static, no dynamic — hand off to Ruby. */
1976
+ hyp_cl_handoff(client_fd, buf, buf_len);
1977
+ *handed_off = 1;
1978
+ return served;
1979
+ }
1697
1980
 
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
- }
1981
+ /* Pull the Host header for env['HTTP_HOST'] / SERVER_NAME. */
1982
+ size_t host_hdr_off = 0;
1983
+ size_t host_hdr_len = 0;
1984
+ (void)hyp_cl_extract_host(buf, (size_t)req_line_end, (size_t)eoh,
1985
+ &host_hdr_off, &host_hdr_len);
1986
+
1987
+ /* Pull peer addr for env['REMOTE_ADDR']. Best-effort; if
1988
+ * getpeername fails (rare on a freshly-accepted fd) we
1989
+ * pass an empty String and let the Ruby helper substitute
1990
+ * 127.0.0.1. */
1991
+ char peer_buf[64];
1992
+ peer_buf[0] = '\0';
1993
+ size_t peer_len = 0;
1994
+ {
1995
+ struct sockaddr_storage ss;
1996
+ socklen_t slen = (socklen_t)sizeof(ss);
1997
+ if (getpeername(client_fd, (struct sockaddr *)&ss, &slen) == 0) {
1998
+ if (ss.ss_family == AF_INET) {
1999
+ struct sockaddr_in *sin = (struct sockaddr_in *)&ss;
2000
+ if (inet_ntop(AF_INET, &sin->sin_addr, peer_buf, sizeof(peer_buf))) {
2001
+ peer_len = strlen(peer_buf);
2002
+ }
2003
+ } else if (ss.ss_family == AF_INET6) {
2004
+ struct sockaddr_in6 *sin6 = (struct sockaddr_in6 *)&ss;
2005
+ if (inet_ntop(AF_INET6, &sin6->sin6_addr, peer_buf, sizeof(peer_buf))) {
2006
+ peer_len = strlen(peer_buf);
2007
+ }
2008
+ }
2009
+ }
2010
+ }
1703
2011
 
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();
2012
+ /* Header blob: from req_line_end to eoh-2 (strips the
2013
+ * trailing CRLF that delimits headers from the empty
2014
+ * line). The Ruby helper tolerates an extra trailing
2015
+ * CRLF, so a few bytes of slack are safe. */
2016
+ size_t hdrs_blob_off = (size_t)req_line_end;
2017
+ size_t hdrs_blob_len = ((size_t)eoh > hdrs_blob_off + 2)
2018
+ ? ((size_t)eoh - hdrs_blob_off - 2)
2019
+ : 0;
2020
+
2021
+ int keep_alive = !connection_close;
2022
+ size_t resp_len = 0;
2023
+ char *resp_buf = hyp_dyn_dispatch_under_gvl(
2024
+ buf + method_off, method_len,
2025
+ buf + path_off, path_only_len,
2026
+ (query_len > 0) ? buf + query_off : "", query_len,
2027
+ (host_hdr_len > 0) ? buf + host_hdr_off : "", host_hdr_len,
2028
+ (hdrs_blob_len > 0) ? buf + hdrs_blob_off : "", hdrs_blob_len,
2029
+ peer_buf, peer_len,
2030
+ block, keep_alive, &resp_len);
2031
+ if (resp_buf == NULL) {
2032
+ /* Dispatch failed (no callback / exception / shape error).
2033
+ * Hand off to Ruby so the connection still gets a
2034
+ * response. */
2035
+ hyp_cl_handoff(client_fd, buf, buf_len);
2036
+ *handed_off = 1;
2037
+ return served;
2038
+ }
1709
2039
 
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);
2040
+ hyp_pc_write_args_t wargs;
2041
+ wargs.fd = client_fd;
2042
+ wargs.buf = resp_buf;
2043
+ wargs.len = resp_len;
2044
+ wargs.total = 0;
2045
+ wargs.err = 0;
2046
+ rb_thread_call_without_gvl(hyp_pc_write_blocking, &wargs,
2047
+ RUBY_UBF_IO, NULL);
2048
+ free(resp_buf);
2049
+
2050
+ if (wargs.err != 0 && wargs.total == 0) {
2051
+ close(client_fd);
2052
+ return served;
2053
+ }
2054
+
2055
+ served++;
2056
+ hyp_cl_tick_request();
2057
+ /* Lifecycle hooks for the dynamic-block path fire INSIDE the
2058
+ * Ruby dispatch helper (it has the env hash in scope), not
2059
+ * via the C-side lifecycle callback. The C-side flag stays
2060
+ * the static-path's hook contract. */
1716
2061
  }
1717
2062
 
1718
2063
  /* Carry pipelined bytes forward into the same buffer. */
@@ -1951,6 +2296,141 @@ static VALUE rb_pc_max_key_len(VALUE self) {
1951
2296
  return INT2NUM(HYP_PC_MAX_KEY_LEN);
1952
2297
  }
1953
2298
 
2299
+ /* ============================================================
2300
+ * 2.14-A — dynamic block registry: Ruby-callable surface.
2301
+ * ============================================================
2302
+ *
2303
+ * `register_dynamic_block(path, method, block)` adds an entry to the
2304
+ * C-side dynamic-block table. Called once per registered route at
2305
+ * server boot from `Server#run_c_accept_loop`. Returns the new entry
2306
+ * count.
2307
+ *
2308
+ * `clear_dynamic_blocks!` empties the table — used by specs and on
2309
+ * server stop so a subsequent boot doesn't see stale entries.
2310
+ *
2311
+ * `set_dynamic_dispatch_callback(callable)` registers the single
2312
+ * Ruby callable the C loop invokes for every dynamic-block hit;
2313
+ * typically a small closure built by the server that captures the
2314
+ * runtime + delegates to `Adapter::Rack.dispatch_for_c_loop`.
2315
+ */
2316
+ static VALUE rb_pc_register_dynamic_block(VALUE self, VALUE rb_path,
2317
+ VALUE rb_method, VALUE rb_block) {
2318
+ (void)self;
2319
+ Check_Type(rb_path, T_STRING);
2320
+ if (NIL_P(rb_block) || !rb_respond_to(rb_block, rb_intern("call"))) {
2321
+ rb_raise(rb_eArgError, "block must respond to #call");
2322
+ }
2323
+
2324
+ const char *path = RSTRING_PTR(rb_path);
2325
+ size_t path_len = (size_t)RSTRING_LEN(rb_path);
2326
+ if (path_len == 0 || path_len > HYP_PC_MAX_KEY_LEN) {
2327
+ rb_raise(rb_eArgError, "path must be 1..%d bytes", HYP_PC_MAX_KEY_LEN);
2328
+ }
2329
+
2330
+ int method_kind = PC_INTERNAL_METHOD_GET;
2331
+ if (SYMBOL_P(rb_method)) {
2332
+ ID m = SYM2ID(rb_method);
2333
+ if (m == rb_intern("HEAD")) method_kind = PC_INTERNAL_METHOD_HEAD;
2334
+ else if (m == rb_intern("GET")) method_kind = PC_INTERNAL_METHOD_GET;
2335
+ else method_kind = PC_INTERNAL_METHOD_OTHER;
2336
+ } else if (RB_TYPE_P(rb_method, T_STRING)) {
2337
+ const char *ms = RSTRING_PTR(rb_method);
2338
+ long mslen = RSTRING_LEN(rb_method);
2339
+ if (mslen == 4 && (ms[0] == 'H' || ms[0] == 'h')) method_kind = PC_INTERNAL_METHOD_HEAD;
2340
+ else if (mslen == 3 && (ms[0] == 'G' || ms[0] == 'g')) method_kind = PC_INTERNAL_METHOD_GET;
2341
+ else method_kind = PC_INTERNAL_METHOD_OTHER;
2342
+ }
2343
+ if (method_kind == PC_INTERNAL_METHOD_OTHER) {
2344
+ rb_raise(rb_eArgError, "2.14-A only supports :GET / :HEAD dynamic blocks");
2345
+ }
2346
+
2347
+ pthread_mutex_lock(&hyp_dyn_lock);
2348
+ if (hyp_dyn_route_count >= HYP_DYN_MAX_ROUTES) {
2349
+ pthread_mutex_unlock(&hyp_dyn_lock);
2350
+ rb_raise(rb_eRuntimeError,
2351
+ "dynamic-block registry full (max %d entries)",
2352
+ HYP_DYN_MAX_ROUTES);
2353
+ }
2354
+
2355
+ /* If a registration for the same (path, method) already exists,
2356
+ * replace it — last writer wins, mirrors RouteTable's contract. */
2357
+ int slot_idx = -1;
2358
+ for (size_t i = 0; i < hyp_dyn_route_count; i++) {
2359
+ hyp_dyn_route_t *r = &hyp_dyn_routes[i];
2360
+ if (r->path_len == path_len && r->method == method_kind &&
2361
+ memcmp(r->path, path, path_len) == 0) {
2362
+ slot_idx = (int)i;
2363
+ break;
2364
+ }
2365
+ }
2366
+ if (slot_idx < 0) {
2367
+ slot_idx = (int)hyp_dyn_route_count;
2368
+ hyp_dyn_routes[slot_idx].path = (char *)malloc(path_len + 1);
2369
+ if (hyp_dyn_routes[slot_idx].path == NULL) {
2370
+ pthread_mutex_unlock(&hyp_dyn_lock);
2371
+ rb_raise(rb_eNoMemError, "register_dynamic_block: path alloc");
2372
+ }
2373
+ memcpy(hyp_dyn_routes[slot_idx].path, path, path_len);
2374
+ hyp_dyn_routes[slot_idx].path[path_len] = '\0';
2375
+ hyp_dyn_routes[slot_idx].path_len = path_len;
2376
+ hyp_dyn_route_count++;
2377
+ /* Anchor the block VALUE against GC. We register the slot's
2378
+ * address once per slot; subsequent writes to the same slot
2379
+ * (replacement on duplicate registration) update the VALUE
2380
+ * in place — the address itself stays anchored. */
2381
+ rb_gc_register_address(&hyp_dyn_routes[slot_idx].block);
2382
+ }
2383
+ hyp_dyn_routes[slot_idx].block = rb_block;
2384
+ hyp_dyn_routes[slot_idx].method = method_kind;
2385
+ pthread_mutex_unlock(&hyp_dyn_lock);
2386
+
2387
+ /* Touch the shared anchor so the registration is observable from
2388
+ * Ruby debugging tools (`ObjectSpace.dump` walks the registered
2389
+ * addresses; a non-nil value here proves at least one block is
2390
+ * pinned by the registry). */
2391
+ hyp_dyn_routes_mark_anchor = rb_block;
2392
+
2393
+ return SIZET2NUM(hyp_dyn_route_count);
2394
+ }
2395
+
2396
+ static VALUE rb_pc_clear_dynamic_blocks_bang(VALUE self) {
2397
+ (void)self;
2398
+ pthread_mutex_lock(&hyp_dyn_lock);
2399
+ for (size_t i = 0; i < hyp_dyn_route_count; i++) {
2400
+ free(hyp_dyn_routes[i].path);
2401
+ hyp_dyn_routes[i].path = NULL;
2402
+ hyp_dyn_routes[i].path_len = 0;
2403
+ hyp_dyn_routes[i].block = Qnil;
2404
+ hyp_dyn_routes[i].method = 0;
2405
+ /* We deliberately do NOT call rb_gc_unregister_address —
2406
+ * the address slots stay registered so they can be reused
2407
+ * by a subsequent register_dynamic_block call. The Qnil
2408
+ * write here is enough to drop the GC pin on the prior
2409
+ * block VALUE; Qnil is statically rooted. */
2410
+ }
2411
+ hyp_dyn_route_count = 0;
2412
+ hyp_dyn_routes_mark_anchor = Qnil;
2413
+ pthread_mutex_unlock(&hyp_dyn_lock);
2414
+ return Qnil;
2415
+ }
2416
+
2417
+ static VALUE rb_pc_dynamic_block_count(VALUE self) {
2418
+ (void)self;
2419
+ pthread_mutex_lock(&hyp_dyn_lock);
2420
+ size_t n = hyp_dyn_route_count;
2421
+ pthread_mutex_unlock(&hyp_dyn_lock);
2422
+ return SIZET2NUM(n);
2423
+ }
2424
+
2425
+ static VALUE rb_pc_set_dynamic_dispatch_callback(VALUE self, VALUE callback) {
2426
+ (void)self;
2427
+ if (!NIL_P(callback) && !rb_respond_to(callback, rb_intern("call"))) {
2428
+ rb_raise(rb_eArgError, "callback must respond to #call");
2429
+ }
2430
+ hyp_dyn_dispatch_callback = callback;
2431
+ return callback;
2432
+ }
2433
+
1954
2434
  /* ============================================================
1955
2435
  * 2.12-D — sharing surface for io_uring_loop.c.
1956
2436
  *
@@ -2128,10 +2608,25 @@ void Init_hyperion_page_cache(void) {
2128
2608
  rb_define_singleton_method(rb_mHyperionHttpPageCache, "bump_c_loop_requests_total_for_test!",
2129
2609
  rb_pc_bump_c_loop_requests_total_for_test_bang, 1);
2130
2610
 
2611
+ /* 2.14-A — dynamic block dispatch surface. */
2612
+ rb_define_singleton_method(rb_mHyperionHttpPageCache, "register_dynamic_block",
2613
+ rb_pc_register_dynamic_block, 3);
2614
+ rb_define_singleton_method(rb_mHyperionHttpPageCache, "clear_dynamic_blocks!",
2615
+ rb_pc_clear_dynamic_blocks_bang, 0);
2616
+ rb_define_singleton_method(rb_mHyperionHttpPageCache, "dynamic_block_count",
2617
+ rb_pc_dynamic_block_count, 0);
2618
+ rb_define_singleton_method(rb_mHyperionHttpPageCache, "set_dynamic_dispatch_callback",
2619
+ rb_pc_set_dynamic_dispatch_callback, 1);
2620
+
2131
2621
  /* Mark-protect the lifecycle / handoff callback slots so the GC
2132
2622
  * doesn't collect them while the C loop is running. */
2133
2623
  rb_gc_register_address(&hyp_cl_lifecycle_callback);
2134
2624
  rb_gc_register_address(&hyp_cl_handoff_callback);
2625
+ /* 2.14-A — anchor the dynamic dispatch callback + per-route block
2626
+ * VALUEs (those are anchored on first registration; this is the
2627
+ * shared marker the dispatch callback hides behind). */
2628
+ rb_gc_register_address(&hyp_dyn_dispatch_callback);
2629
+ rb_gc_register_address(&hyp_dyn_routes_mark_anchor);
2135
2630
 
2136
2631
  id_fileno_pc = rb_intern("fileno");
2137
2632
  id_to_io_pc = rb_intern("to_io");