hoplite.socket
hoplite.socket is Hoplite’s typed OpenResty-compatible cosocket surface.
Network operations suspend the Hara coroutine while Nginx continues serving
other connections. Application code uses namespace functions; raw public
service/operation strings and native socket handles are not part of the API.
The current production TCP slice provides:
- numeric IPv4 and IPv6 connections;
- hostname lookup through the configured nonblocking Nginx resolver;
- explicit Unix-domain stream targets;
- fixed, line, all-data, any-data, and delimiter receive modes;
- send-direction half-close;
- bounded timeout and socket-option configuration; and
- worker-local keepalive pools with monotonic expiry, bounded capacity, real reuse counts, and bounded FIFO connect backlog.
Simultaneous one-reader/one-writer operation, TLS, and UDP remain pending. See the OpenResty cosocket compatibility matrix for row-level status, evidence, and implementation owners.
Basic TCP client
Section titled “Basic TCP client”(ns example.redis-line (:require [hoplite.socket :as socket]))
(defn ^:async request-line [address port request] (let [client (std.foundation.coroutine/await (socket/tcp)) configured (std.foundation.coroutine/await (socket/settimeouts client 1000 1000 1000)) connected (std.foundation.coroutine/await (socket/connect client address port))] (if (get connected 1) connected (let [sent (std.foundation.coroutine/await (socket/send client request)) received (if (get sent 1) [nil (get sent 1) nil] (std.foundation.coroutine/await (socket/receive client "*l")))] (std.foundation.coroutine/await (socket/close client)) received))))Descriptor authority
Section titled “Descriptor authority”tcp returns an opaque typed descriptor with protocol
hoplite.socket/0-alpha. The visible numeric handle is not a file descriptor.
Native validation binds it to the exact request, work, worker, socket kind, and
generation.
Do not persist a descriptor, place it in a shared zone, serialize it, or pass it
to another request. Invalid, stale, wrong-kind, and wrong-owner descriptors
reject with structured ex-info.
A descriptor is retired exactly once by explicit close, successful
setkeepalive, request cleanup, cancellation, client disconnect, timeout
teardown, or worker shutdown.
Result convention
Section titled “Result convention”Network and lifecycle outcomes remain ordinary closed vectors.
| Operation | Success | Network/lifecycle failure |
|---|---|---|
connect, shutdown, close, timeout setters, setoption, setkeepalive |
[1 nil] |
[nil error] |
send |
[bytes-sent nil] |
[nil error] |
receive, receiveany, receiveuntil reader |
[data nil nil] |
[nil error partial] |
getreusedtimes |
[count nil] |
[nil error] |
Malformed arguments, invalid option maps, foreign descriptors, and unsupported state transitions reject the host Promise as programming or authority errors. Refusal, DNS failure, timeout, peer closure, pool capacity, backlog overflow, and other network/lifecycle outcomes resolve normally so mechanically translated OpenResty control flow remains natural.
(std.foundation.coroutine/await (socket/tcp))Creates one request-scoped TCP descriptor. It does not open a native connection
until connect.
connect
Section titled “connect”Numeric TCP
Section titled “Numeric TCP”(std.foundation.coroutine/await (socket/connect client "127.0.0.1" 6379))Numeric IPv4 and IPv6 connects run only through Nginx’s nonblocking event loop.
Hostname TCP
Section titled “Hostname TCP”(std.foundation.coroutine/await (socket/connect client "redis.internal" 6379))Hostname lookup runs only through the resolver configured in the Nginx HTTP context:
http { resolver 10.0.0.2 ipv6=off valid=30s; resolver_timeout 2s;}Hoplite never falls back to blocking getaddrinfo. The DNS and connect stages
share the socket’s connect budget, additionally bounded by resolver_timeout.
When several addresses are returned, Hoplite selects one in stable address
order.
A missing resolver resolves as [nil "resolver not configured"]. NXDOMAIN
resolves as [nil "host not found"]. Resolver refusal, timeout, cancellation,
client disconnect, and later connect failures remain ordinary cosocket results.
The resolver context is released exactly once.
Unix-domain stream
Section titled “Unix-domain stream”(std.foundation.coroutine/await (socket/connect client "unix:/run/example/service.sock"))Only explicit absolute targets beginning with unix:/ enter the Unix-domain
path. Empty, relative, embedded-NUL, and overlong targets are programming
errors. Missing or refused sockets use the normal [nil error] result.
Numeric TCP, hostname TCP, and Unix-domain streams have isolated pool identity and never collide.
Pool options
Section titled “Pool options”(std.foundation.coroutine/await (socket/connect client "redis.internal" 6379 {:pool "redis-primary" :pool-size 32 :backlog 64}))The options map accepts:
| Option | Current contract |
|---|---|
:pool |
Explicit stable pool name. |
:pool-size |
Positive bounded worker-local capacity for the pool key. |
:backlog |
Integer from 0 through 4096. Zero returns [nil "connection pool full"] at capacity; positive values suspend up to that many FIFO waiters. |
A matching clean idle connection is checked out before Hoplite creates a new native connection. Pool identity distinguishes transport, canonical destination, hostname identity, explicit pool name, and connection-semantic socket options. The identity is extensible for TLS SNI, verification, session, and client-certificate state.
When active plus idle connections already consume the pool size, positive
:backlog retains the exact request/work/call in a worker-local FIFO queue.
Returning a clean idle connection hands it directly to the oldest matching
waiter before opening another connection. A full wait queue resolves as
[nil "too many waiting connect operations"]. Cancellation, client disconnect,
explicit close, connection failure, timeout, and worker shutdown remove or wake
queued work exactly once; no worker thread, condition variable, or polling loop
is introduced.
(std.foundation.coroutine/await (socket/send client "PING\r\n"))Accepts String or Bytes values up to 1 MiB per call. Partial kernel writes resume from the Nginx write event. A send timeout or ambiguous connection failure closes the connection because its protocol position is no longer safe to reuse.
shutdown
Section titled “shutdown”(std.foundation.coroutine/await (socket/shutdown client "send"))The current slice supports only the "send" direction. It sends the stream FIN
after queued bytes while leaving receive open, which supports peers that wait
for request EOF before responding.
Further sends resolve as [nil "closed"]. Receive and exactly-once close
remain valid. Other directions are programming errors rather than aliases for a
full close. Half-closed connections are not eligible for keepalive.
receive
Section titled “receive”(socket/receive client 16) ;; exactly 16 bytes(socket/receive client "*l") ;; one line, without LF or a preceding CR(socket/receive client "*a") ;; all bytes until the peer closesEach call is bounded to 1 MiB. A timeout or premature close returns bytes already consumed in the third result slot.
receiveany
Section titled “receiveany”(socket/receiveany client 4096)Returns after at least one byte is available, without waiting to fill the maximum or for peer EOF. The maximum is from 1 through 1,048,576 bytes.
Unlike OpenResty’s two-value Lua result, Hoplite preserves the closed receive
shape: [data nil nil] or [nil error partial].
receiveuntil
Section titled “receiveuntil”(let [reader (std.foundation.coroutine/await (socket/receiveuntil client "--boundary--"))] (std.foundation.coroutine/await (reader)))Awaiting the constructor creates one request-scoped reader function. Calling it without a size returns the bytes before the next delimiter. The reader may be called repeatedly and preserves bytes after each match. Delimiters may span any number of kernel-read boundaries.
Use {:inclusive true} to include the delimiter:
(socket/receiveuntil client "_END_" {:inclusive true})A positive reader size creates bounded chunks. Once the delimiter is consumed,
the reader returns [nil nil nil] once before resetting for the next delimiter:
(std.foundation.coroutine/await (reader 4096))Patterns are bounded to 4096 bytes and individual results to 1 MiB. A read timeout leaves the connection open and returns consumed bytes as the partial value. Other connection failures close it.
Timeouts
Section titled “Timeouts”(socket/settimeout client 2000)(socket/settimeouts client 500 2000 3000)Timeouts are milliseconds from 0 through 3,600,000. Zero disables the
corresponding timer.
settimeout applies one value to connect, send, and read. settimeouts assigns
them independently. For hostname connects, the connect budget begins before
DNS. For a queued pool connect, it begins before backlog admission. Only the
remaining duration is available to DNS and/or the native connect stage.
setoption
Section titled “setoption”(socket/setoption client "keepalive" true)(socket/setoption client "reuseaddr" 1)(socket/setoption client "tcp-nodelay" true)(socket/setoption client "sndbuf" 32768)(socket/setoption client "rcvbuf" 32768)setoption is available only on an established connection. The compatibility
set is deliberately closed:
keepalive,reuseaddr, andtcp-nodelayaccept Boolean or0/1;sndbufandrcvbufaccept bounded non-negative integers.
Unsupported names and values are programming errors rather than an arbitrary
native setsockopt escape hatch. Kernel application failure resolves as
[nil error].
Connection-semantic option state participates in safe reuse eligibility and pool identity. Hoplite never silently reuses an incompatible connection.
Keepalive pooling
Section titled “Keepalive pooling”Return a connection
Section titled “Return a connection”(std.foundation.coroutine/await (socket/setkeepalive client 30000 32))Arity forms are:
(socket/setkeepalive client)(socket/setkeepalive client idle-timeout-ms)(socket/setkeepalive client idle-timeout-ms pool-size)A successful call returns [1 nil], transfers the clean established connection
to its worker-local idle pool, and retires the descriptor. Do not use that
descriptor again.
A connection is rejected rather than pooled when it is closed, failed, half-closed, unread/dirty, has pending state, has become stale, or cannot enter the bounded pool. Request cleanup does not close a connection after ownership has successfully transferred to the pool.
Idle entries use monotonic expiry. EOF, peer data, reset, timeout, error, deterministic eviction, worker reload, and worker exit close them exactly once.
Read the reuse count
Section titled “Read the reuse count”(std.foundation.coroutine/await (socket/getreusedtimes client))Returns [count nil]. A newly established connection reports zero. Each prior
successful checkout from the matching worker-local pool increments the count.
(std.foundation.coroutine/await (socket/close client))Closes the native connection and retires the descriptor. Request cleanup is
idempotent with explicit close. close never returns a connection to a pool.
If the close releases the last active slot for a pool with queued connects, the
oldest matching waiter is admitted immediately.
Error compatibility
Section titled “Error compatibility”The public API already uses stable ordinary errors for key outcomes, including
timeout, closed, host not found, resolver not configured,
connection pool full, and too many waiting connect operations.
The centralized platform-independent error catalogue, including refusal/reset,
same-direction busy, TLS, UDP, and policy outcomes, is tracked by
issue #194. Application
code should not depend on arbitrary operating-system strerror wording.
Phase and concurrency boundary
Section titled “Phase and concurrency boundary”The delivered slice runs from suspended HTTP content handlers. Broader phase legality remains tracked by issue #164.
The current request host-operation scheduler serializes native operations. It does not yet claim OpenResty’s simultaneous one-reader/one-writer legality. Independent directional state and deterministic same-direction busy results are tracked by issue #191.
No production path uses a blocking resolver, blocking socket compatibility library, source evaluation, or a second socket runtime.
OpenResty translation
Section titled “OpenResty translation”| OpenResty Lua | Hoplite Hara |
|---|---|
ngx.socket.tcp() |
(await (socket/tcp)) |
sock:connect(host, port) |
(await (socket/connect sock host port)) |
sock:connect("unix:/path") |
(await (socket/connect sock "unix:/path")) |
sock:connect(host, port, {pool=..., pool_size=..., backlog=...}) |
(await (socket/connect sock host port {:pool ... :pool-size ... :backlog ...})) |
sock:send(value) |
(await (socket/send sock value)) |
sock:shutdown("send") |
(await (socket/shutdown sock "send")) |
sock:receive("*l") |
(await (socket/receive sock "*l")) |
sock:receiveany(max) |
(await (socket/receiveany sock max)) |
sock:receiveuntil(pattern) |
(await (socket/receiveuntil sock pattern)) |
sock:settimeout(ms) |
(await (socket/settimeout sock ms)) |
sock:settimeouts(c, s, r) |
(await (socket/settimeouts sock c s r)) |
sock:setoption(name, value) |
(await (socket/setoption sock name value)) |
sock:setkeepalive(timeout, size) |
(await (socket/setkeepalive sock timeout size)) |
sock:getreusedtimes() |
(await (socket/getreusedtimes sock)) |
sock:close() |
(await (socket/close sock)) |
await abbreviates std.foundation.coroutine/await.
Pending APIs
Section titled “Pending APIs”These names are not silently emulated:
| Surface | Owner |
|---|---|
| Concurrent one-reader/one-writer state | #191 |
sslhandshake and configured client identity |
#192 |
udp, setpeername, UDP send/receive |
#193 |
| Stable complete error mapping | #194 |
| Deterministic lifecycle fuzzing | #195 |
| Structured lifecycle events and bounded metrics | #196 |