NAME
Hyperman - an event-loop PSGI server
SYNOPSIS
use Hyperman;
my $app = sub { [ 200, [ 'Content-Type' => 'text/plain' ], [ 'Hello' ] ] };
Hyperman->run( app => $app, port => 8080, workers => 4 );
# async: a handler may return a Hyperman::Future of the response
my $async = sub {
my $env = shift;
Hyperman->timer(0.5)->then(sub {
[ 200, [ 'Content-Type' => 'text/plain' ], [ 'later' ] ];
});
};
DESCRIPTION
Hyperman is a PSGI server built on a prefork supervisor with a per-worker XS event loop (Hyperman::Loop over a pluggable readiness backend), aiming to match a JIT HTTP server's throughput without a JIT. Handlers may return a Hyperman::Future (or any Future-compatible object) of the PSGI response; the connection is parked while the worker keeps serving, and awaiting a future inside a handler pumps the worker's own loop. Runs any Plack app via plackup -s Hyperman.
The entire implementation is XS: the loop, HTTP machinery, Futures, and the process model live in C (include/hyperman/), with per-package XS interfaces (xs/).
run
Hyperman->run(
app => $psgi_app, # required
host => '0.0.0.0',
port => 8080, # a scalar, or an arrayref of ports
# ([80, 8080]) for several plain
# listeners sharing these options
workers => 0, # 0/unset = one per CPU; 1 = in-process
# dev mode (no supervisor)
idle_timeout => 60, # close idle keep-alive conns (secs)
header_timeout => 30, # slow/partial request guard (secs)
max_pipeline => 32, # requests per conn per wakeup
reuseport => 0, # per-worker listeners (SO_REUSEPORT;
# Linux accept scaling - regressed on
# macOS, keep off there)
access_log => '/var/log/app.log', # fast built-in Combined-log
# writer (a path, or an open handle
# like \*STDERR). Or pass a coderef
# for custom logging:
# sub { my ($env,$status,$bytes)=@_ }
# ($bytes is undef for streaming)
max_body => 16777216, # request ceiling, bytes (see below);
# the default is 16MB, 0 is refused
max_requests_per_worker => 0, # recycle a worker after N requests
shutdown_grace => 30, # bound on graceful drain (secs)
affinity => 0, # pin worker i to core i%ncpu (Linux)
http2 => 0, # accept HTTP/2 (h2c, and h2 over TLS via
# ALPN); needs the nghttp2 build
tls_cert => $cert_pem, # serve HTTPS (needs the OpenSSL build,
tls_key => $key_pem, # Hyperman->has_tls); both required
tls_ca => $ca_pem, # verify client certs against this CA
tls_verify => 'require', # none (default) | optional | require
tls_sni => { # per-hostname certificates (SNI)
'other.example' => { cert => $c2, key => $k2 },
},
deny => ['1.2.3.4'], # IPs dropped at accept (see below)
deny_capacity => 1024, # denylist table size (0 = default)
rate_capacity => 4096, # rate-counter table size (0 = default)
bus_slots => 2048, # message bus: ring depth
bus_slot_size => 2048, # ... and the largest message
bus_groups => 64, # ... and how many named queue groups
);
The event backend is chosen automatically (kqueue, io_uring, epoll, then poll) and can be forced with HYPERMAN_BACKEND.
bus_slots and bus_slot_size answer two different complaints and it is worth knowing which is which: raise bus_slots when a burst is outrunning a reader (the symptom is a climbing gaps count), and bus_slot_size when a publish is refused for being too big. The default 2048 x 2KB costs four megabytes of shared memory for the whole pool. See "The message bus".
Multiple listeners
A single run can bind several listeners, each independently plain or TLS - the common case being plain :80 beside HTTPS :443. Pass listen an arrayref of per-listener hashrefs; each takes its own port (required) plus any of host, tls_cert, tls_key, tls_ca, tls_verify, tls_sni, http2, and redirect_https. A missing per-listener field falls back to the top-level value of the same name, and the top-level port/listen are mutually exclusive shorthands - port => [80, 8080] is sugar for several plain listeners sharing the top-level options.
Hyperman->run(
app => $app,
workers => 8,
listen => [
{ port => 80, redirect_https => 443 }, # 301 every request to https
{ port => 443, tls_cert => $cert, tls_key => $key },
],
);
redirect_https makes a listener answer every request with a 301 to the same host and request-target on the given https port (in C, before the app is reached); the value is that target port, and a bare true value means the standard 443. Its SERVER_PORT and, for TLS listeners, psgi.url_scheme reflect the listener the request arrived on. All listeners serve the one app. Ports below 1024 (80, 443) still require the process to start as root or hold CAP_NET_BIND_SERVICE.
access_log
When access_log is a path or an open filehandle, Hyperman formats an Apache-style Combined Log Format line
host - user [dd/Mon/YYYY:HH:MM:SS +ZZZZ] "METHOD URI PROTO" status bytes "referer" "ua"
entirely in C and appends it to the file, with no per-request Perl call on the hot path. The timestamp is cached and recomputed at most once a second, lines are buffered and flushed once per event-loop wakeup, and a file target is opened once in the parent and shared O_APPEND across workers so their lines stay interleaved without tearing. Quoted fields are escaped, so a hostile URI or User-Agent cannot forge a log line. REMOTE_ADDR is captured at accept and is also visible to the app in $env.
Pass a coderef instead (sub { my ($env, $status, $bytes) = @_ }) for full control; that path calls back into Perl for every response.
max_body: the request ceiling
max_body is the largest request Hyperman will accept - headers plus body. Over it, the request is answered 413 Payload Too Large and the connection is closed. The default is 16MB.
Hyperman->run(app => $app, max_body => 64 * 1024 * 1024);
It is no longer a memory ceiling
It used to be one, and the two are worth telling apart now that they are different numbers.
A body larger than 1MB is written to a temp file as it arrives, and psgi.input is a handle on that file rather than a second copy in memory. So the memory a request costs is bounded by that threshold, not by max_body - a 128MB upload measured at about 15MB of worker RSS, against roughly 275MB before the change.
Which means max_body can be raised for large uploads without raising what a worker holds. It is a policy ceiling now: how large a request this service is willing to accept, and the thing standing between a form and a full filesystem.
The temp file is unlinked the moment it is created, so the handle is its only reference. It goes away when the request ends, when a handler dies, or when the worker is killed - there is nothing to clean up and nothing on disk for another process to find. TMPDIR chooses where it lives.
Two limits, stated plainly:
The threshold is a build-time constant (
HM_SPILL_BODY), not yet a run-time option.Chunked bodies are not spilled. There is no
Content-Lengthto divert on and the decoder wants the octets contiguous, so aTransfer-Encoding: chunkedrequest still accumulates in memory and is still bounded bymax_body.
Response compression
With compress => 1, responses are gzipped on the way out.
Hyperman->run(app => $app, compress => 1);
Off by default, here and under Plack::Handler::Hyperman. A server that starts compressing because it was upgraded is a surprise, and the cost is real - see the compress_level table below. Whether it is worth paying depends on whether bandwidth or CPU is what limits a given deployment, which is something only its operator knows.
A response is compressed only when all of these hold:
the request's
Accept-Encodingaccepted gzip (q=0is an explicit refusal and is honoured)the response carries no
Content-Encodingof its ownthe body is a plain in-memory body, not a filehandle, reader or streamed source - compressing those would undo the sendfile and drip paths
it is at least
compress_min_lengthbytes (default 1400, one MTU)the status is 200 or 203 - never 206, whose
Content-Rangedescribes offsets into the uncompressed representationthe
Content-Typeis on the compressible allowlist:text/*,application/json,application/javascript,application/xml,application/x-ndjson,image/svg+xml, and anything ending+jsonor+xml. An allowlist, so a new binary media type never becomes compressible by default.
The Content-Encoding contract
Hyperman never touches a response that already declares a Content-Encoding. That one rule is the whole interface with whatever is above it:
a framework serving precompressed
.gzfiles off disk setsgzip, and its bytes go out as they area route that wants no compression sets
Content-Encoding: identity, which Hyperman honours and strips before writing
It is a plain response header, so it is a contract any PSGI framework can use rather than a private arrangement. Punk's { compress => 0 } route option is exactly this spelling.
Two things it changes about your response
The ETag is rewritten - a -gzip suffix is added inside the closing quote. The layer that compresses must be the layer that fixes the validator, or a shared cache serves the compressed bytes to a client that asked for none. nginx does the same. It is said plainly here because otherwise an application author finds it by debugging.
Vary: Accept-Encoding is added whenever a response was compressed, for the same reason.
An app-supplied Content-Length is replaced, since it described the bytes that were just replaced.
compress_level
Default 1, not the customary 6, on measurement rather than convention. A 37KB JSON document, 2 workers / 5s / 64 connections on loopback:
off 130,000 req/s 37.3 KB/req
level 1 66,900 req/s 3.19 KB/req 11.7x smaller, 1.9x slower
level 6 26,300 req/s 2.96 KB/req 12.6x smaller, 4.9x slower
Level 6 buys 7% more compression for 2.5x the CPU. Almost all of the win on repetitive text - which is what an API payload is - lands in the first level. Raise it with compress_level => 6 if bandwidth costs you more than CPU does; the range is 1 to 9.
Read that table carefully: a loopback benchmark charges the whole CPU cost and pays none of the benefit, because the network is free. It is the worst case for compression and the best case for sending 37KB uncompressed. Once the link is finite, the same two numbers say the opposite thing - requests actually served, taking whichever of CPU and bandwidth binds first:
link without gzip with gzip winner
100 Mbps 327 3,827 gzip, 11.7x
1 Gbps 3,273 38,267 gzip, 11.7x
10 Gbps 32,727 66,900 gzip, 2.0x
25 Gbps 81,817 66,900 uncompressed, 1.2x
40 Gbps 130,000 66,900 uncompressed, 1.9x
Compression loses only above roughly 20 Gbps of egress per host, which is not a situation a PSGI application is usually in. Below that, halving the CPU per response buys nothing because the CPU was not the constraint.
Small responses are unaffected either way: anything under compress_min_length is never touched, and a benchmark of tiny bodies measures no difference at all.
Without zlib
compress and compress_min_length are accepted and inert, so one configuration is portable across builds. Hyperman->has_compression is the honest answer.
HTTP/2
With http2 => 1 (requires the nghttp2 build; Hyperman->has_http2 reports it), a connection whose first bytes are the HTTP/2 preface is served as cleartext HTTP/2 (h2c, prior knowledge) via nghttp2: multiplexed streams, HPACK, and flow control, with each stream dispatched to the app like any request (sync, Hyperman::Future, or psgi.streaming responses all work). HTTP/1.1 remains the default on the same port.
TLS / HTTPS
With tls_cert/tls_key (PEM paths; requires the OpenSSL build, Hyperman->has_tls), the listener serves HTTPS: a non-blocking TLS handshake is driven on the event loop, then reads/writes go through OpenSSL. The certificate and key are validated once before forking, so a bad pair fails fast. Combined with http2 => 1, ALPN negotiates h2 vs http/1.1 per connection on the same port - the path browsers use for HTTP/2. psgi.url_scheme is https for TLS requests, and HTTPS is set to on.
Client certificates (mTLS). tls_verify (optional/require, with tls_ca as the trust anchor) requests and checks a client certificate; require fails the handshake without a valid one. The result is surfaced in $env the mod_ssl way: SSL_CLIENT_VERIFY (SUCCESS/FAILED/NONE), SSL_CLIENT_S_DN, SSL_CLIENT_I_DN, plus SSL_PROTOCOL/SSL_CIPHER.
SNI (multiple certificates). tls_sni maps hostnames to their own cert/key; the certificate is chosen from the TLS ServerName, falling back to tls_cert/tls_key. See "tls_reload" for replacing that map while the server is running.
The library. Hyperman->tls_library returns the runtime TLS library banner (OpenSSL 3.0.13 30 Jan 2024, LibreSSL 3.8.2), or undef when TLS support was not built. The two stacks agree on the API but not on behaviour - LibreSSL performs no TLS 1.3 session resumption, for one - and this is how a deployment (or a test) tells them apart.
h2c Upgrade. With http2 => 1 over cleartext, an HTTP/1.1 request carrying Upgrade: h2c is answered with 101 Switching Protocols and the connection continues as HTTP/2 (the original request becomes stream 1) - as well as the prior-knowledge preface. Over TLS, h2 is chosen by ALPN instead.
With workers > 1 a supervisor process manages the pool: a crashed worker is respawned (exponential backoff on crash loops; clean exits respawn immediately), SIGHUP or SIGUSR2 recycles all workers with zero downtime (new workers start, old ones drain gracefully), SIGUSR1 makes every worker dump its stats to stderr, and SIGTERM / SIGINT drain - bounded by shutdown_grace - and exit. With workers => 1 the server runs in the calling process, no supervisor.
timer / io_ready
my $f = Hyperman->timer($secs); # Future, resolves after $secs
my $g = Hyperman->io_ready($fh, 'r'); # Future, resolves on readiness
Both require a running worker loop. Hyperman->loop returns the current worker's Hyperman::Loop (or undef outside one).
tls_reload
my $n = Hyperman->tls_reload(\%sni); # listeners rebuilt
Replace this worker's TLS certificates without replacing the process.
A listener's SSL_CTX is built once, in the parent, before the fork - so a certificate issued while the server is running is not served, and SIGHUP does not help because it re-forks from that same parent. This is the way to pick one up. %sni is the same shape run takes, { host => { cert => $path, key => $path } }, and replaces the map entirely rather than merging into it.
Call it from inside a worker - from the app, or from a timer on Hyperman->loop. Each worker holds its own context pointer (the fork copied it), so a reload changes that worker and no other; a pool picks the new certificate up as each worker calls it. The listening socket is never touched, so nothing is unbound and no connection is refused, and connections already handshook keep the one they have. The next connection that worker accepts uses the new certificate.
Returns the number of listeners rebuilt: 0 outside a worker, 0 on a plain-only server, and 0 when the rebuild would have served less than what is already running - either because the default certificate would not load, or because a per-host one would not and that host would have silently dropped to the fallback. In every one of those cases the running certificates are left exactly as they were, and the reason is printed to STDERR.
A worker respawned after a crash, or recycled by SIGHUP, inherits the parent's boot-time context again and needs its own tls_reload. That is a feature of where the context is built, not a bug here: whatever drives the reload should drive it per worker rather than once.
stats
my $s = Hyperman->stats; # in-app, inside a worker
# { requests => N, accepts => N, denied => N, bytes_out => N,
# connections => N, backend => 'kqueue', pid => $$ }
Per-worker counters; returns undef outside a running loop. denied counts connections dropped at accept by the denylist (see below).
detach
my $fd = Hyperman::detach($env);
Hand a live HTTP/1 connection to the application. The server removes its watchers for the socket, forgets the connection and does not close it, so from that point the application's own psgix.loop watchers on that descriptor are what drive it. This is the seam a protocol upgrade needs: before it, a hijacked socket raced the server's own read watcher, which consumed post-upgrade bytes into a buffer nothing drained.
The application writes its own upgrade response - the server's writer is out of the picture - and returns [101, [], []], which is discarded. Returns the real file descriptor.
my $conn = $env->{'psgix.hyperman.conn'}; # [ fd, generation id ]
psgix.hyperman.conn is both the ticket detach works from and the way an application detects that detaching is possible at all: it is absent on HTTP/2 and on servers that are not Hyperman. Detaching croaks, with the reason, when the connection is gone or the ticket is stale, on HTTP/2, on TLS, when output is still queued, or when it has already been detached.
Detach is legal from a Future continuation - asynchronous authentication before an upgrade - but not from a psgi.streaming responder, which has already forced Connection: close, nor once response bytes are queued.
The equivalent for a C consumer is the table's conn_detach.
Denylist and rate limiting
Hyperman maps a small anonymous shared-memory arena before it forks its workers, holding an IP denylist and a set of fixed-window rate counters. It is shared, not per-worker, on purpose: a counter kept per worker would let a 100/min limit through at workers x 100/min, and a denylist kept per worker would be a different list on each. Because the mapping is inherited across the fork, every worker reads and writes the one copy.
The denylist is enforced at accept: a blocked peer's connection is closed before a connection object is built or a byte is read - the cheapest possible rejection - and the worker's denied stat counts it. Seed it statically with run(deny => ['1.2.3.4', ...]); deny_capacity and rate_capacity size the two tables (both default, and both round-trip a few thousand entries).
The rate counters are a fixed window: at most limit hits against a key in each window-second wall-clock slot, the first hit of a new slot finding a stale record and zeroing it - no timer, no sweep. A slot whose window has rolled is reclaimable, so when the keys that filled the table go quiet their slots are reused on demand by new keys rather than lingering; sizing rate_capacity above the peak of distinct keys in a window keeps live counters from evicting each other (an over-capacity eviction resets a counter, so it would leak looser, never tighter). N gateways behind a load balancer admit up to N times the limit, which is honest rather than a distributed count this does not implement.
An XS module reaches all of this through the C ABI below - deny_check / deny_add / deny_remove and ratelimit_hit - which is how Punk's rate_limit keyword and $c->block_ip are built, with no Perl on the hot path. The same four are also plain class methods, for an application that is not an XS module:
Hyperman->deny_add($ip, $ttl_seconds); # 0 = until the server stops
Hyperman->deny_remove($ip);
my $blocked = Hyperman->deny_check($ip);
my ($allowed, $remaining, $reset)
= Hyperman->ratelimit_hit($key, $limit, $window);
ratelimit_hit counts one hit against $key in the current window and says whether it is within $limit; $reset is the epoch second the window rolls, which is what an X-RateLimit-Reset header wants. A $limit of 0 is unlimited and reports a $remaining of -1. $window defaults to 60.
Reach for these rather than a Perl equivalent, because a Perl equivalent is wrong in a way that is hard to see: a hash in the worker gives a different denylist per worker, and a counter in the worker turns a limit of $n into workers x $n. The arena is the one copy all of them share.
All four fail open when there is no arena - outside a running server, or on a platform without the atomics it needs - so nothing is denied and nothing is limited. That is the same answer the accept path gives itself, and the safe one for a check that could not run. Note that a denylist entry added from inside a request is enforced at accept from then on, so it takes effect on the next connection, not the one that added it.
The message bus
# anywhere: a request handler, a timer, another worker
Hyperman->publish('room:lobby', $bytes);
# in a worker, usually from on_worker_start
Hyperman->subscribe('room:lobby' => sub {
my ($topic, $payload) = @_;
$_->send($payload) for $room->clients;
});
A publish/subscribe bus across the whole worker pool, on a second fork-shared arena beside the one above. No Redis, no hub process, no new prerequisite.
It exists because a prefork server makes anything held in a worker a lie about the pool. Punk::WebSocket::Room says so in its own documentation: a room is per worker, so under workers => 4 a broadcast reaches roughly a quarter of the people in it, the call succeeds, and nobody is told. The same shape applies to Server-Sent Events, to cache invalidation, and to anything else one worker learns and the others need.
Two delivery modes, and where the cursor lives
A message is published once. The whole difference is where the cursor lives:
Fanout - the default. Each subscriber keeps its cursor in its own process, so every subscriber sees every message. This is what a chat room wants.
A queue group -
group => $name. One cursor lives in the arena and is advanced with an atomic compare-and-swap, so exactly one member of the pool sees each message. This is load-balanced work distribution.
The balancing is not implemented, it is a consequence. A worker that is busy is not in the claim, so it does not take anything, so the free workers take the traffic. There is no scheduler and nothing to tune.
Both may be live on the same topic at once: a room can fan out to every worker's connections while a group of workers takes one copy each to write to a log. A group consumes only its own cursor, so the fanout readers still see everything.
Which to use, and when NOT to use this at all
Delivery is at-most-once. A worker that claims a message and then dies loses it, and the loss is counted rather than retried. Nothing survives a restart: the ring is memory, and memory is what makes it fast.
If losing the message matters, this is the wrong tool. Punk::Queue is the right one.
. Punk::Queue a bus queue group
delivery at-least-once at-most-once
survives a restart yes no
worker dies mid-work retried counted, and lost
latency milliseconds microseconds
storage Postgres/SQLite shared memory
Sending an email, charging a card, resizing an upload somebody paid for: the queue. Telling the other workers a cache key changed, fanning a chat message out, nudging every worker to re-read a config: the bus.
Overflow: bounded, drop-oldest, counted
The ring is fixed. When a reader falls far enough behind, the ring wraps past what it has not read yet, and those messages are gone.
Every part of that is deliberate. Bounded, because an unbounded buffer in front of a stalled worker is a memory leak with a schedule - one wedged consumer would grow it until the server dies and takes the healthy workers with it. Drop-oldest, because when a system is in trouble the recent messages are the ones anybody wants. Counted, and the count reachable, because a silently short chat room is indistinguishable from a quiet one.
my %s = Hyperman->bus_stats('thumbs');
warn "bus dropped $s{gaps}" if $s{gaps};
warn "group lost $s{group_gaps}" if $s{group_gaps};
A climbing count means the reader is too slow or bus_slots is too small. In measurement, nothing is lost until a publisher is running at over a million messages a second - far past a rate any consumer can be scheduled at, which is exactly when dropping the oldest is the right answer.
The ring cannot grow to fit, and would not if it could. It is a shared mapping inherited by every worker at a fixed address and size, so growing it would mean every already-forked process mapping new pages in step, which there is no mechanism to do. Size it with bus_slots instead.
The wakeup
A publish pokes every other worker through a descriptor it has been watching since it started, so the message arrives in microseconds rather than whenever that worker next happens to look. Measured publish-to-wake across processes is under a fifth of a millisecond.
A burst does not become a storm: a worker with a poke already in flight is not poked again, so a thousand publishes in a loop produce a single wakeup rather than a thousand.
Methods
Hyperman->publish($topic, $payload)-
1on the ring,0local-only (there is no arena, so nobody else will see it),-1refused as too big for a slot. Three outcomes rather than true or false, because "sent to the pool" and "sent to myself" are different facts and a caller that cannot tell them apart cannot diagnose anything.An oversize message is refused, never truncated: a truncated WebSocket frame is a protocol violation delivered to every member of a room.
Hyperman->subscribe($topic, $cb, group => $name)-
Returns an id, or
-1. The callback is invoked as$cb->($topic, $payload)from this worker's event loop.groupis the only difference between the two delivery modes.A subscriber that dies has its death turned into a warning. It runs from the event loop with nothing to unwind into, and one bad handler must not remove a worker from the pool.
Hyperman->unsubscribe($id)Hyperman->receive-
Everything published since this process last asked, as
[$topic, $payload]pairs. The manual alternative tosubscribe, for a process that is not on a Hyperman loop. Hyperman->claim($topic, $group)-
The same, load-balanced.
$groupdefaults to the topic.A subscription begins at "from now on". Both a
subscribeand the firstclaimon a group start at the current position, not at the oldest message still in the ring - so register before publishing, and expect nothing back from a claim on a group that did not exist when the message was sent.That is deliberate. The alternative is that a worker restarting at three in the morning replays every message still in the ring, all at once, to a process that has just come up - which is the moment it can least afford it.
Hyperman->dispatch-
Runs the subscriptions by hand and returns how many messages reached a subscriber. Under a worker the wakeup does this; outside one, this is the poll that stands in for it.
Hyperman->bus_reset-
Point this process's cursor at "from now on". A worker does this for itself; a process that forks by hand must, or the child replays what the parent already handled.
Hyperman->bus_stats($group)-
published,gaps, andgroup_gapswhen a group is named.gapsis what this PROCESS missed - messages the ring wrapped past before it read them - counted across both of its readers,receiveand the dispatcher behindsubscribe. Nothing callsreceiveunder a server, so a count of only that one would read zero however much a busy worker's subscriptions dropped. Hyperman->bus_live-
Whether there is a shared ring at all.
Topics match exactly. A hierarchy is a naming convention - room:lobby, room:support - dispatched by the caller, since receive hands back the topic with every message.
Where it is not there
Local-only on Windows, and on a compiler without the atomics the arena needs. publish returns 0 and reaches this process's own subscribers; a queue group degrades to "this process is the only member", which is correct rather than convenient. That is the same answer deny_check and ratelimit_hit give, and it is the tested path rather than a fallback nobody exercises.
on_worker_start
Hyperman->on_worker_start(sub {
$dbh = DBI->connect(...); # this child's own handle
srand; # this child's own seed
});
Hyperman->run(app => $app, workers => 4);
Registers a callback that runs once in every worker, after the fork and before that worker's loop starts turning. Register before run: the registry is read in the child, so a callback added afterwards will never reach the workers already running.
A prefork server needs this and PSGI has no standard for it. Anything holding a file descriptor - a database handle, a cache connection - is wrong in a child that inherited it from the parent, and sharing one across workers corrupts it in ways that look like anything but the cause.
Returns 1, or 0 when the table is full (8 callbacks). Croaks on a non-coderef, at registration. If the callback dies the death becomes a warning and the worker carries on serving: a worker that could not run your setup code is still a worker, and one that never starts is an outage - so check what you depend on rather than assuming it ran.
The C ABI's on_worker_start is the same registry, for a consumer that would rather register a C function than a coderef.
C ABI
Hyperman exposes a public C ABI so that other XS modules can drive its event loop and futures entirely from C - fd watchers and timers whose callbacks run with no Perl call frame per event, and Hyperman::Future create/settle/inspect without method dispatch. The motivating consumer is DBIx::Loop, whose pure-XS loop adapter watches database socket fds and settles futures on the worker loop C-to-C.
Getting the header
The contract lives in include/hyperman/hm_abi.h. Two ways to reach it:
ExtUtils::Depends (no copying). Hyperman is a provider: building installs hm_abi.h and writes Hyperman::Install::Files, so a dependent's Makefile.PL that says
my $pkg = ExtUtils::Depends->new('My::Consumer', 'Hyperman');
WriteMakefile( ..., $pkg->get_makefile_vars );
picks up hm_abi.h on its include path automatically - but makes Hyperman a configure-time dependency of the consumer.
Vendoring a pinned copy. Because the table is resolved at runtime and the header is pure declarations, a consumer that wants Hyperman to stay optional (as DBIx::Loop does) instead ships its own copy of hm_abi.h pinned at a known HM_ABI_VERSION and simply fails its resolve when Hyperman is not loaded.
Perl headers (EXTERN.h / perl.h / XSUB.h) must be included before hm_abi.h.
The table
#define HM_ABI_VERSION 5
#define HM_ABI_READ 0x1 /* io_watch masks */
#define HM_ABI_WRITE 0x2
#define HM_ABI_PENDING 0 /* future_state */
#define HM_ABI_DONE 1
#define HM_ABI_FAILED 2
#define HM_ABI_CANCELLED 3
typedef struct hm_abi_timer hm_abi_timer; /* opaque handle */
typedef void (*hm_abi_io_cb)(pTHX_ int fd, int mask, void *ud);
typedef void (*hm_abi_timer_cb)(pTHX_ void *ud);
typedef void (*hm_abi_ready_cb)(pTHX_ SV *future, void *ud);
typedef void (*hm_abi_worker_cb)(pTHX_ void *loop, void *ud);
typedef struct hm_abi {
int abi_version; /* == HM_ABI_VERSION */
/* loop handles (opaque hm_loop*) */
void *(*cur_loop)(pTHX);
void *(*loop_of_sv)(pTHX_ SV *loop_sv);
SV *(*sv_of_loop)(pTHX_ void *loop);
/* persistent fd watchers, pure C dispatch */
void (*io_watch)(pTHX_ void *loop, int fd, int mask,
hm_abi_io_cb cb, void *ud);
void (*io_unwatch)(pTHX_ void *loop, int fd, int mask);
/* one-shot timer with cancellation */
hm_abi_timer *(*timer)(pTHX_ void *loop, double secs,
hm_abi_timer_cb cb, void *ud);
void (*timer_cancel)(pTHX_ void *loop, hm_abi_timer *t);
/* Hyperman::Future */
SV *(*future_new)(pTHX);
int (*is_future)(pTHX_ SV *sv);
IV (*future_state)(pTHX_ SV *f);
void (*future_done)(pTHX_ SV *f, SV **vals, SSize_t n);
void (*future_fail)(pTHX_ SV *f, SV *err);
void (*future_on_ready)(pTHX_ SV *f, hm_abi_ready_cb cb, void *ud);
/* await: pump the loop until f settles (re-entrant) */
void (*run_until)(pTHX_ void *loop, SV *f);
/* v2: detach a live HTTP/1 connection (see Hyperman::detach).
* 0 ok; -1 no such connection or a stale id; -2 HTTP/2; -3 TLS;
* -4 output still queued; -5 already detached. */
int (*conn_detach)(pTHX_ void *loop, int fd, UV id);
/* v3: abuse controls on the fork-shared arena (no pTHX, no SV;
* process-global; fail open with no arena). deny_check is the
* lock-free accept-path check; ratelimit_hit is a fixed window,
* returns 1 within the limit and 0 over, filling *remaining and
* *reset (the epoch the window rolls) when non-NULL. */
int (*deny_check)(const char *ip);
void (*deny_add)(const char *ip, long ttl_secs);
void (*deny_remove)(const char *ip);
int (*ratelimit_hit)(const void *key, STRLEN klen,
IV limit, IV window, IV *remaining, IV *reset);
/* v4: run cb once in every worker, AFTER the fork, in the child,
* with that child's own loop, before the loop starts turning.
* Register before run(). Fires in the single-worker case too.
* Returns 1, or 0 when the table is full. */
int (*on_worker_start)(pTHX_ hm_abi_worker_cb cb, void *ud);
/* v5: the cross-worker message bus */
int (*bus_publish)(const char *topic, STRLEN tlen,
const char *payload, STRLEN plen);
int (*bus_subscribe)(pTHX_ const char *topic, STRLEN tlen,
const char *group, STRLEN glen,
hm_abi_bus_cb cb, void *ud);
int (*bus_unsubscribe)(pTHX_ int id);
} hm_abi;
on_worker_start is the seam for anything a consumer owns that is bound to an event loop. A flush timer, a wakeup listener, a metrics reader: none of them can be created before run, because the loop they would attach to is not the loop that ends up serving - a prefork server has forked by then, and on kqueue the child's copy of the descriptor is not even valid. This is the one moment where the loop exists, belongs to the process that will serve, and has not started turning.
static void on_worker(pTHX_ void *loop, void *ud) {
A->timer(aTHX_ loop, 5.0, flush, ud); /* the child's own loop */
}
A->on_worker_start(aTHX_ on_worker, ud); /* before run() */
"on_worker_start" is the same registry as a class method, for an application that wants to reopen a database handle rather than attach a watcher. A Perl callback is handed no loop - Hyperman->loop is the worker's own once it is running - because the useful thing to do with the raw pointer is a C entry point.
Hyperman::_abi_ptr
my $iv = Hyperman::_abi_ptr;
Returns the address of the process-wide hm_abi table as an integer (an IV). A consumer calls this once (at BOOT, or lazily on first use), INT2PTRs it to a const hm_abi *, and checks ->abi_version >= HM_ABI_VERSION (the version it was compiled against) before using it. Not intended to be called from Perl for any other purpose. Hyperman::_abi_selftest exercises the whole table from C and returns 1; it exists for Hyperman's own test suite.
Loop handles
cur_loop returns the currently running loop or NULL - inside a Hyperman worker it is the worker's loop, so a consumer resolving at request time needs no loop object at all. loop_of_sv unwraps a Hyperman::Loop SV (croaks on anything else); sv_of_loop returns the loop's blessed wrapper SV (+1, caller owns), the same shared wrapper psgix.loop uses. The handle is opaque; pass it back to every loop-taking entry.
Watchers and timers
io_watch installs a persistent C watcher for one direction of one fd (HM_ABI_READ or HM_ABI_WRITE - one direction per call). The callback fires on every readiness event with no Perl call frame, receiving the fd, the direction that fired, and ud. Installing replaces any existing watcher (C, Perl callback, or future) for that fd+direction; io_unwatch is idempotent and safe from inside the callback. fd must be below Hyperman's fd ceiling (65536); a bad fd croaks.
timer arms a one-shot timer and returns an opaque handle; timer_cancel disarms a timer that has not fired yet. The handle dies when the callback fires - never cancel after the fire (clear any stored handle inside the callback). Cancel live timers before dropping a loop.
Futures
future_new returns a pending Hyperman::Future (+1, caller owns). future_done / future_fail settle it and run its continuations through the normal trampoline; both are no-ops on an already-settled future, and values are copied, not stolen. future_state returns the HM_ABI_* state without dispatch. future_on_ready attaches a C continuation that fires exactly once when the future settles - including cancellation (check future_state to see which); this is how a consumer hooks cancellation, e.g. issuing a database cancel when a caller cancels the future. It fires immediately if the future is already settled.
run_until pumps the loop until the given future settles. It is re-entrant - calling it from inside a callback nests, exactly like ->get inside a Hyperman worker - so a blocking-style await can be built on it directly.
The message bus (v5)
static void on_msg(pTHX_ const char *topic, STRLEN tl,
const char *payload, STRLEN pl, void *ud) { ... }
A->bus_subscribe(aTHX_ "room:lobby", 10, NULL, 0, on_msg, ud);
A->bus_publish("room:lobby", 10, bytes, len);
The C door onto "The message bus", so a consumer can fan a message across the worker pool without a Perl frame on either side.
group NULL is fanout - this process sees every message on the topic. A group name makes it a queue group - exactly one member of the pool sees each message. One entry point, because they are one mechanism.
bus_publish returns 1 on the ring, 0 local-only (no arena), -1 refused as oversize. Like the v3 abuse controls it takes no pTHX: the arena is process-global and the publish path touches no SV.
The callback is subject to the same contract as every other in this table and one that matters more here: it must not croak. It is reached from the event loop with no Perl frame to unwind into, so a death would take the worker down - and for a chat server that means one bad handler silently removing a worker from the pool.
Delivery is at-most-once and drops oldest under pressure, counting what it dropped. A consumer that cannot lose the message wants Punk::Queue.
Abuse controls (v3)
The v3 entries reach the fork-shared arena described under "Denylist and rate limiting". They take no pTHX and touch no SV - they operate only on that process-global arena - so a consumer may call them from any worker, and they fail open when no arena is mapped.
deny_check($ip) returns 1 if $ip (an INET6_ADDRSTRLEN string) is denylisted and unexpired, else 0; it is lock-free, being the check Hyperman itself runs on every accept. deny_add($ip, $ttl_secs) adds or refreshes an entry ($ttl_secs 0 = permanent); deny_remove($ip) lifts one.
ratelimit_hit(key, klen, limit, window, &remaining, &reset) counts one hit against the opaque key (klen bytes) under limit per window seconds, returning 1 within the limit and 0 over, and filling *remaining (never below zero) and *reset (the epoch the window rolls) when they are non-NULL. A limit of 0 or less is unlimited. The window is fixed and a function of the clock, exactly as the arena describes.
Punk builds its rate_limit keyword, $c->block_ip and $c->rate_hit on these four.
The same four are class methods for a consumer that is not an XS module - see "Denylist and rate limiting". They are the same arena and interchangeable with these; what differs is a Perl call per invocation instead of none, which is why the framework-level keywords are built on the C entries.
Contracts
Everything is single-threaded and fires on the loop thread, inside the loop's dispatch:
C callbacks must not croak. Trap errors (
G_EVALaround any Perl you call) and settle a future instead; a longjmp out of the dispatch loop leaves it inconsistent.udlifetime is the consumer's problem: it must stay valid until the watcher is removed, the timer fires or is cancelled, oron_readyfires. Remove watchers before freeing theirud.Callbacks may re-enter the table freely, including
run_until.The table only ever grows at the end.
HM_ABI_VERSIONbumps on any append; a consumer requiresabi_version >=the version it was written against.
Example: resolve and use
#include "hm_abi.h" /* via ExtUtils::Depends, or a vendored copy */
static const hm_abi *HM = NULL; /* resolved on first use */
static const hm_abi *my_hm(pTHX) {
if (!HM) {
dSP; int n;
ENTER; SAVETMPS; PUSHMARK(SP); PUTBACK;
n = call_pv("Hyperman::_abi_ptr", G_SCALAR | G_EVAL);
SPAGAIN;
if (!SvTRUE(ERRSV) && n > 0) {
IV p = POPi;
if (p) {
const hm_abi *a = INT2PTR(const hm_abi *, p);
if (a->abi_version >= HM_ABI_VERSION) HM = a;
}
} else if (n > 0) (void)POPs;
PUTBACK; FREETMPS; LEAVE;
}
return HM; /* NULL => Hyperman absent or too old: fall back */
}
/* watch a DB socket; on readable, collect the result and settle */
static void on_db_readable(pTHX_ int fd, int mask, void *ud) {
my_req *r = (my_req *)ud; /* must not croak */
if (collect_result(r) == DONE) {
HM->io_unwatch(aTHX_ r->loop, fd, HM_ABI_READ);
if (r->timeout) {
HM->timer_cancel(aTHX_ r->loop, r->timeout);
r->timeout = NULL;
}
HM->future_done(aTHX_ r->future, &r->result_sv, 1);
}
}
/* fire a query: future + fd watch + timeout, all C */
void *loop = HM->cur_loop(aTHX); /* the worker's loop */
r->future = HM->future_new(aTHX);
HM->io_watch(aTHX_ loop, db_fd, HM_ABI_READ, on_db_readable, r);
r->timeout = HM->timer(aTHX_ loop, 5.0, on_db_timeout, r);
HM->future_on_ready(aTHX_ r->future, on_settled_or_cancelled, r);
AUTHOR
LNATION <email@lnation.org>
LICENSE AND COPYRIGHT
This software is Copyright (c) 2026 by LNATION.
This is free software, licensed under the Artistic License 2.0.