Skip to content

Begin typing to search this documentation.

hoplite.socket

Experimental

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.

(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))))

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.

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.

(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.

(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.

(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.

(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.

(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.

(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 closes

Each call is bounded to 1 MiB. A timeout or premature close returns bytes already consumed in the third result slot.

(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].

(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.

(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.

(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, and tcp-nodelay accept Boolean or 0/1;
  • sndbuf and rcvbuf accept 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.

(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.

(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.

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.

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 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.

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