A teensyp-compatible TCP server for
jolt (Clojure on Chez Scheme) — no JVM,
no Java NIO. It runs a single jolt.net readiness reactor plus handler and
callback pools, and mirrors the teensyp.* namespaces so teensyp-style handlers
run unmodified.
This is to teensyp what ring-chez-adapter is to Ring: a native reimplementation of the server contract on jolt's owned sockets.
(require '[teensyp.server :as tcp]
'[teensyp.buffer :as buf])
(defn echo-handler
([sock] {:bytes 0}) ;; accept: initial per-conn state
([state sock b] ;; read: b is a teensyp.buffer/Buffer
(let [n (buf/remaining b)]
(when (pos? n)
(tcp/write sock (buf/wrap (buf/get-bytes! b n)))) ;; consume + echo
(update state :bytes + n)))
([state ex] (println "closed, bytes =" (:bytes state)))) ;; close: ex nil if graceful
(def server (tcp/run-server :port 3000 :handler echo-handler :reuse-address? true))
;; ... later ...
(tcp/stop-server server) ;; also works with (with-open [s (tcp/run-server ...)] ...)The handler is one function with three arities — accept, read, close
— threading a per-connection state value, exactly as in JVM teensyp. Calls are
serial per connection: accept first, close last, reads sequential, and the write
queue is drained before the next read.
A production blocking outbound client over jolt.net:
(connect host port)/(connect host port opts)and(connect-endpoint endpoint opts)resolve once and try candidates in resolver order.:connect-timeout-msdefaults to 30000; explicitly passing:connect-timeout-ms nilselects an unbounded connect. Callers that already compose deadlines may pass one absolute:deadline-nanosinSystem/nanoTimeunits. The one deadline covers resolution and every candidate rather than restarting for each address. The currentjolt.netresolver is synchronous: an over-deadline result is rejected, but an in-flightgetaddrinfocannot yet be preempted.(send-all! connection bytes)and its slice arity block until every byte is written, correctly advancing across partial writes and retrying thejolt.net/would-blocksentinel. Both forms accept an additive options arity with either relative:timeout-msor absolute:deadline-nanos; omitting both remains unbounded.(receive-into! connection bytes off len)returns a positive count, zero for an empty slice, or nil at EOF.(receive-at-most! connection n)provides the allocating convenience form. Both receive forms accept the same optional deadline map.(shutdown-write! connection)half-closes once while preserving reads.(close! connection)is idempotent, and the connection also works withwith-open.(connection-info connection)returns transport-neutral local/remote endpoints and lifecycle state, never a socket, poller, or native descriptor.
Each connection owns persistent read and write pollers. Same-direction
operations are FIFO-serialized by an owner-independent promise gate, while one
reader and one writer may proceed concurrently. Closing wakes both directions
before retiring the socket; no operation allocates a poller. Operation
deadlines are computed at API entry and include time queued behind an earlier
same-direction operation. A timed-out queue ticket is cancelled without taking
ownership. Deadline expiry throws :teensyp.client/timed-out with
:teensyp.client/op set to :send or :receive; EOF and native reset/close
remain their original transport outcomes.
(run-server & opts)/(run-server opts-map)— start the server; returns a handle usable withstop-serverandwith-open. Options::port(required),:handler(required),:read-buffer-size(8192),:write-buffer-size(32768),:write-queue-size(64),:control-queue-size(32),:accept-batch-size(64),:reuse-address?,:recv-buffer-size,:executor,:pool-size(4),:callback-executor,:callback-pool-size(2),:shutdown-executor?(false),:shutdown-callback-executor?(false),:error-logger, and:stop-timeout-ms(5000).:error-loggeris called on a reactor-side error. The offending connection is closed, but the reactor keeps running — one bad connection never takes down the server.:callback-executorruns write/control completion callbacks, and is deliberately separate from:executor. A handler may block until one of its writes completes (that is how a blocking sink over a socket is built); if the releasing callback shared the handler pool, enough concurrent blocked handlers would deadlock at exactlypool-size. Passing the same executor object for both roles is rejected. The default callback executor is a small, configurable fixed pool rather than an eager cached pool.:accept-batch-sizebounds the work one listener-readiness turn can perform. Stop is checked between accepts, and any accepted socket that fails registration or loses cancellation before publication is closed and its registration rolled back.- Supplied
:executorand:callback-executorvalues are borrowed by default and remain usable after the server stops. Pools created by the server are always shut down. Set:shutdown-executor? trueand/or:shutdown-callback-executor? trueto let the server adopt and shut down a supplied pool, including cleanup after a failed start.
(stop-server server)— stop accepting, quiesce active handlers, retire every registration and owned socket, then run and await each open connection's close-arity before closing the poller and owned executors. While quiescing, the reactor continues draining writes and control callbacks required by already-active handlers, but accepts no connections and dispatches no new reads. Repeated calls are idempotent. A cleanup timeout throws structuredExceptionInfowith:err :teensyp.server/stop-timeout; cleanup continues, and a later call waits on the same completion.(write sock buffer)/(write sock buffer callback)— queue a Buffer; the zero-arg callback fires once fully written and intentionally remains success-only.(write-completion sock buffer)— queue a Buffer and return a promise that settles with{:status :written}after full success or{:status :failed :exception ex}on connection/write failure. Queue-capacity errors remain synchronous. A synchronous::socket-closedadmission error retains the first recorded connection failure as itsex-cause. Blocking adapters should use this API so a native write error cannot strand a callback waiter.(close sock)/(close sock callback)— queue the socket to close.(pause-reads sock)/(resume-reads sock)— backpressure controls.(peer-closed? sock)— true once the peer has half-closed its write side, so no more data will arrive. The connection is not closed for you. The peer may still be reading and is usually owed a response, and the reactor cannot tell when one is still coming (a handler on a worker thread raises no flag), so the handler owns the close. The read arity is called once when this becomes true, possibly with an empty buffer.Socketprotocol:try-write,queue-control,queue-write,socket-info,socket-lock. The additiveCompletionSocketprotocol suppliesqueue-write-completionwithout changing existingSocketimplementations.
A byte-array-backed ByteBuffer equivalent (jolt has no java.nio.ByteBuffer):
buffer, wrap, str->buffer, buffer->str, remaining, has-remaining?,
position/set-position!, limit/set-limit!, flip, compact, duplicate,
index-of, index-of-array, read-line, copy, get-bytes!, put-bytes!.
Charset arguments are name strings (e.g. "UTF-8"), since jolt has no
java.nio.charset.Charset.
A jolt-native blocking-connection adapter (jolt cannot subclass
java.io.InputStream/OutputStream). stream-handler runs (f conn) on its
own thread, where conn supports conn-recv, conn-read-line, conn-send,
conn-close, backed by a core.async channel the reader fills. conn-send
blocks until the write outcome is known and throws the recorded native
exception on failure.
A compatibility shim for the former real-loopback helper. Despite the legacy
namespace name, it has no FFI bindings or raw-descriptor API: all operations
delegate to the production teensyp.client surface.
- Transport: a
jolt.netpoller instead of an NIOSelector; all socket I/O and registration mutation runs on the reactor thread, sotry-writealways queues (returns false). The completion callback and queue limits are unchanged. - Buffer:
teensyp.buffer/Buffer(byte-array backed) in place ofjava.nio.ByteBuffer; consume by advancingposition, exactly as before. Handlers using theteensyp.bufferfunctions are drop-in; handlers that called raw.position/.remainingJava interop switch tobuf/position/buf/remaining. - Charsets: name strings, not
Charsetobjects. - Streams:
teensyp.streamexposes aconnprotocol, notjava.iostreams. - socket-info: transport-neutral numeric endpoint maps
{:host string :port integer :family keyword}in:local-addressand:remote-address, plus:fdfor diagnostics only. The public TCP boundary does not exposejolt.net-namespaced data keys. - Platform: follows the readiness targets implemented by
jolt.net; currently Linux x86_64, Linux aarch64, macOS arm64, and macOS x86_64. Windows readiness is a follow-up. Windows x86_64 and aarch64 source-mode portable gates do not change that runtime boundary.
- Linux x86_64 and macOS arm64 have observed native runtime evidence for the complete reactor, real-loopback acceptance, outbound-client, and property suites with source-built Chez 10.4.1.
- Linux aarch64 (
ubuntu-24.04-arm) and macOS x86_64 (macos-15-intel) have equivalent full-runtime jobs configured. Treat both as candidate coverage until those jobs are observed green on this revision. Because libhegel 0.33.0 publishes no Darwin/x86_64 asset, the Intel job builds its exact tagged source and supplies the resulting library explicitly. - Windows x86_64 has candidate native layered gates. The first source-builds
Chez, loads the production TCP namespaces, runs the platform-independent
outbound-client models with no optional test dependency, and requires
jolt.net/open-pollerto fail with:unsupported-target. The second installs libhegel and runs the descriptor-independent buffer properties. The pinnedjolt.netincludes reviewed W1/W2 blocking and non-blocking byte-I/O substrate, but neither gate is socket-runtime support: no TCP server/client loopback test is enabled until Windows readiness exists. Both Windows source-mode lanes apply the checked-in, fail-closed v0.7.27 drive/UNC absolute-path compatibility patch; remove it when that host-I/O fix is available in the minimum supported Jolt release. They also select an absoluteJOLT_GITLIBS_DIRso v0.7.27's native mkdir, subprocess clone, and marker write operate on one cache tree. - Windows aarch64 has a non-gating public-preview source-mode lane on
windows-11-vs2026-arm. It builds nativetarm64ntChez 10.4.1 and runs the descriptor-independentteensyp.bufferproperty suite through the upstream Windows ARM64 libhegel asset. Because jolt-net's newly probed ARM64 facts have not yet been reviewed into a descriptor, this lane requires target selection to fail closed and does not load the client/server namespaces. It uses no packaged joltc, devboot, or AOT cache.
jolt -A:test -m hegel.install
JOLT_HEGEL_REQUIRED=1 jolt -M:testThe normal dependency graph pins the reviewed stock-v0.7.27-compatible
jolt-net topic commit. Required Hegel mode prevents a missing native library
from silently skipping the property layers.
Three layers, all gated by the single -M:test command, which exits
non-zero on any failure.
Acceptance (teensyp.server-test) — the framework-less harness. Starts
servers on loopback and drives echo, line-reverse (with split frames),
number-doubling, write-then-close, write-callback ordering, pause/resume
backpressure, the stream layer, and 12 concurrent connections over real TCP.
Deterministic lifecycle regressions additionally cover active-only task
tracking, fixed callback-pool bounds and executor separation, CAS queue caps and
byte-credit rollback, atomic close exceptions, bounded/cancelled accept
rollback, CLOSED readiness clearing, and success/failure write outcomes.
Buffer properties (teensyp.buffer-property-test) — generative properties for
teensyp.buffer via jolt-hegel,
run under clojure.test. Round trips, the 0 <= position <= limit <= capacity
invariant, compact/copy/duplicate conservation, and agreement between
index-of/index-of-array/read-line and independent naive implementations —
plus a stateful model replaying generated operation sequences against a Buffer,
mirroring the server's read path.
TCP properties (teensyp.server-property-test) — properties over real
loopback connections, using the size-based chunking generator so the engine picks
the split points (the acceptance suite pins one):
- echo conservation under arbitrary chunking, with
:read-buffer-size 64so ordinary payloads repeatedly overflow the read buffer and exercise compaction, theFULLflag and the resume path; - line framing invariance under arbitrary chunking;
- chained write-callback ordering;
- pause/resume backpressure losing nothing;
- a single large write, driving partial-send/would-block handling;
teensyp.streamline framing under arbitrary chunking.
Every case ends in a half-close and drains to a real EOF — bounded in time, never
slept on — so the EOF path is covered on every case. The stream property was
promoted into the default gate after 30 consecutive 25-case stress runs
completed cleanly; run-stream-property! remains available for focused stress.
The bounded reactor lifecycle
proofs preserve the
ownership/generation boundary delegated to jolt.net, plus the EOF
byte-visibility, recursive-lock, admission, shutdown, and write-outcome
arguments together with their SAT/UNSAT controls and executable runtime
witnesses. The separate outbound-client proof
record covers absolute deadline
composition, resolver-candidate/socket/poller ownership, partial byte I/O, and
the public loopback contract.
jolt-hegel is a test-only dependency, pinned by SHA in the :test alias.
Property failures print a replayable seed.
jolt AOT cache caveat. Released jolt's namespace cache can run stale code or omit top-level initialization effects. Run development and validation with
JOLT_AOT_CACHE=0; the reviewed fork's launcher already defaults to that fail-safe setting. Namespace-level runtime reuse is not a supported correctness boundary for this project.
EPL-2.0 OR GPL-2.0-or-later, matching teensyp.