Releases: ponylang/lori
0.10.0
Fix accept loop spinning on persistent errors
Previously, when TCPListener's accept loop encountered a non-EWOULDBLOCK error (such as running out of file descriptors), it would retry immediately in a tight loop. Since persistent errors like EMFILE never resolve on their own, this caused the listener to spin indefinitely, consuming CPU without making progress.
The accept loop now exits on any error, letting the ASIO event system re-notify the listener. This gives other actors a chance to run and potentially free resources before the next accept attempt.
Fix read loop not yielding after byte threshold
The POSIX read loop in TCPConnection was missing a return after scheduling a deferred _read_again when the byte threshold was reached. This meant the loop continued reading from the socket in the same behavior call indefinitely under sustained load, preventing per-actor GC from running (GC only runs between behavior invocations) and queuing redundant _read_again messages. The read loop now correctly exits after reaching the threshold, allowing GC and other actors to run before resuming.
Add IPv4-only and IPv6-only support
Lori now supports restricting connections to a specific IP protocol version. Client constructors (TCPConnection.client, TCPConnection.ssl_client) and TCPListener accept an optional ip_version parameter that defaults to DualStack (existing behavior).
Pass IP4 to restrict to IPv4 only or IP6 for IPv6 only:
// IPv4-only listener
_tcp_listener = TCPListener(listen_auth, "127.0.0.1", "7669", this
where ip_version = IP4)
// IPv4-only client
_tcp_connection = TCPConnection.client(auth, "127.0.0.1", "7669", "", this,
this where ip_version = IP4)
// IPv6-only client
_tcp_connection = TCPConnection.client(auth, "::1", "7669", "", this, this
where ip_version = IP6)
// SSL client with IPv4 only
_tcp_connection = TCPConnection.ssl_client(auth, sslctx, "127.0.0.1", "7669",
"", this, this where ip_version = IP4)Server-side constructors (server, ssl_server) don't need this parameter — they accept an already-connected fd whose protocol version was determined by the listener.
Change TCPListener parameter order
The ip_version parameter on TCPListener.create now comes before limit. Since ip_version is a hard requirement in many environments while limit is rarely set, the more commonly used parameter should come first.
If you were passing limit positionally:
// Before
_tcp_listener = TCPListener(listen_auth, host, port, this, 100)
// After
match MakeMaxSpawn(100)
| let limit: MaxSpawn =>
_tcp_listener = TCPListener(listen_auth, host, port, this where limit = limit)
endChange MaxSpawn to a constrained type
MaxSpawn is now a constrained type that rejects invalid values at construction time. Previously it was a bare (U32 | None) type alias, which meant a limit of 0 would silently create a listener that refused every connection. The new type guarantees the value is at least 1.
// Before — bare U32
_tcp_listener = TCPListener(listen_auth, host, port, this where limit = 100)
// After — construct via MakeMaxSpawn
match MakeMaxSpawn(100)
| let limit: MaxSpawn =>
_tcp_listener = TCPListener(listen_auth, host, port, this where limit = limit)
endChange default connection limit to 100,000
Listeners without an explicit limit parameter now cap at 100,000 concurrent connections (DefaultMaxSpawn) rather than having no limit. This is a safer default for production systems. Pass None to restore the old unlimited behavior:
// New default — 100,000 connections (no code change needed)
_tcp_listener = TCPListener(listen_auth, host, port, this)
// Restore old unlimited behavior
_tcp_listener = TCPListener(listen_auth, host, port, this where limit = None)[0.10.0] - 2026-03-03
Fixed
- Fix accept loop spinning on persistent errors (PR #208)
- Fix read loop not yielding after byte threshold (PR #209)
Added
- Add IPv4-only and IPv6-only support (PR #205)
Changed
0.9.0
Allow yielding during socket reads
Under sustained inbound traffic, a single connection's read loop can monopolize the Pony scheduler. yield_read() lets the application exit the read loop cooperatively, giving other actors a chance to run. Reading resumes automatically in the next scheduler turn.
Call yield_read() from within _on_received() to implement any yield policy — message count, byte threshold, time-based, etc.:
fun ref _on_received(data: Array[U8] iso) =>
_received_count = _received_count + 1
// Yield every 10 messages to let other actors run
if (_received_count % 10) == 0 then
_tcp_connection.yield_read()
endUnlike mute()/unmute(), which persistently stop reading until reversed, yield_read() is a one-shot pause — the read loop resumes on its own without explicit action. The library does not impose any built-in yield policy; the application decides when to yield.
Add structured failure reasons to connection callbacks
The failure callbacks _on_connection_failure, _on_start_failure, and _on_tls_failure now carry a reason parameter that identifies why the failure occurred. This is a breaking change — all implementations of these callbacks must be updated to accept the new parameter.
Before
fun ref _on_connection_failure() =>
// No way to know what went wrong
None
fun ref _on_start_failure() =>
None
fun ref _on_tls_failure() =>
NoneAfter
fun ref _on_connection_failure(reason: ConnectionFailureReason) =>
match reason
| ConnectionFailedDNS => // Name resolution failed
| ConnectionFailedTCP => // All TCP attempts failed
| ConnectionFailedSSL => // SSL handshake failed
end
fun ref _on_start_failure(reason: StartFailureReason) =>
match reason
| StartFailedSSL => // SSL session or handshake failed
end
fun ref _on_tls_failure(reason: TLSFailureReason) =>
match reason
| TLSAuthFailed => // Certificate/auth error
| TLSGeneralError => // Protocol error
endThe reason types are union type aliases of primitives, following the same pattern as StartTLSError and SendError. Applications that don't need the reason can add the parameter and ignore it.
[0.9.0] - 2026-03-02
Added
- Allow yielding during socket reads (PR #200)
Changed
- Add structured failure reasons to connection callbacks (PR #202)
0.8.5
Fix wraparound error going from milli to nano in IdleTimeout
IdleTimeout now enforces a maximum value of 18,446,744,073,709 milliseconds (~213,503 days). Previously, very large millisecond values would silently overflow when converted to nanoseconds internally, resulting in an incorrect (much shorter) timeout. Values above the maximum are now rejected during construction with a ValidationFailure.
[0.8.5] - 2026-02-20
Fixed
- Fix wraparound error going from milli to nano in IdleTimeout (PR #196)
0.8.4
Add per-connection idle timeout
idle_timeout() sets a per-connection timer that fires _on_idle_timeout() when no data is sent or received for the configured duration. The duration is an IdleTimeout constrained type (constructed via MakeIdleTimeout) that guarantees a non-zero millisecond value. The timer resets on every successful send() and every received data event, and automatically re-arms after each firing.
fun ref _on_started() =>
match MakeIdleTimeout(30_000) // 30 seconds
| let t: IdleTimeout =>
_tcp_connection.idle_timeout(t)
end
fun ref _on_idle_timeout() =>
_tcp_connection.close()Uses a per-connection ASIO timer event — no extra actors or shared state needed. Call idle_timeout(None) to disable.
[0.8.4] - 2026-02-20
Added
- Add per-connection idle timeout (PR #194)
0.8.3
Widen send() to accept multiple buffers via writev
send() now accepts (ByteSeq | ByteSeqIter), allowing multiple buffers to be sent in a single writev syscall. This avoids both the per-buffer syscall overhead of calling send() multiple times and the cost of copying into a contiguous buffer.
// Single buffer — same as before
_tcp_connection.send("Hello, world!")
// Multiple buffers — one writev syscall
_tcp_connection.send(recover val [as ByteSeq: header; payload] end)Internally, all writes now use writev, including single-buffer sends.
Fix FFI declarations for exit() and pony_os_stderr()
The FFI declarations for exit() and pony_os_stderr() used incorrect types (U8 instead of I32 for the exit status, Pointer[U8] instead of Pointer[None] for the FILE* stream pointer). This caused compilation failures when lori was used alongside other packages that declare the same FFI functions with the correct C types.
[0.8.3] - 2026-02-19
Fixed
- Fix FFI declarations for exit() and pony_os_stderr() (PR #191)
Added
- Widen send() to accept multiple buffers via writev (PR #190)
0.8.2
Add local_address() to TCPListener
TCPListener now exposes local_address(), returning the net.NetAddress of the bound socket. This is essential when binding to port "0" (OS-assigned port) — without it, there's no way to discover the actual port the listener is using.
fun ref _on_listening() =>
let addr = _listener().local_address()
env.out.print("Listening on port " + addr.port().string())[0.8.2] - 2026-02-17
Added
- Add local_address() to TCPListener (PR #189)
0.8.1
Fix spurious _on_connection_failure() after hard_close()
The 0.8.0 fixes for hard_close() and close() during the connecting phase introduced a regression: _on_connection_failure() could fire spuriously after hard_close() completed on an already-connected session. Applications would receive _on_connection_failure() after _on_closed() had already fired, which is an invalid callback sequence.
[0.8.1] - 2026-02-12
Fixed
- Fix spurious _on_connection_failure() after hard_close() (PR #185)
0.8.0
Add first-class LibreSSL support
LibreSSL is now a first-class supported SSL library. LibreSSL users previously had to build with ssl=0.9.0, which forced LibreSSL through a code path designed for ancient OpenSSL. This silently disabled ALPN negotiation, PBKDF2 key derivation, and the modern EVP/init APIs that LibreSSL supports.
Projects that build against LibreSSL should switch from ssl=0.9.0 to ssl=libressl.
This change comes via an update to ponylang/ssl 2.0.0.
Drop OpenSSL 0.9.0 support
OpenSSL 0.9.0 is no longer supported. The ssl=0.9.0 build option and the -Dopenssl_0.9.0 define have been removed. LibreSSL users who previously used ssl=0.9.0 should switch to ssl=libressl.
This change comes via an update to ponylang/ssl 2.0.0.
Fix hard_close() being a no-op during connecting phase
hard_close() was silently ignored when called during the Happy Eyeballs connecting phase (before any connection attempt succeeded). If a connection attempt later succeeded, the connection would go live as if hard_close() was never called.
hard_close() now properly cancels the connecting attempt. It marks the connection as closed so that any subsequent Happy Eyeballs successes are cleaned up instead of establishing a live connection, and fires _on_connection_failure() to notify the application.
Fix close() being a no-op during connecting phase
close() was silently ignored when called during the Happy Eyeballs connecting phase (before any connection attempt succeeded). The connection attempt would eventually complete cleanup, but no lifecycle callback ever fired — the application called close() and never heard back.
close() now properly cancels the connecting attempt. Once all in-flight Happy Eyeballs connections have drained, _on_connection_failure() fires to notify the application that the connection attempt is done.
[0.8.0] - 2026-02-12
Fixed
- Fix hard_close() being a no-op during connecting phase (PR #178)
- Fix close() being a no-op during connecting phase (PR #181)
Added
- Add first-class LibreSSL support (PR #177)
Changed
- Drop OpenSSL 0.9.0 support (PR #177)
0.7.2
Add TLS upgrade support (STARTTLS)
Lori now supports upgrading an established plaintext TCP connection to TLS mid-stream via start_tls(). This enables protocols like PostgreSQL, SMTP, and LDAP that negotiate TLS after an initial plaintext exchange.
actor MyClient is (TCPConnectionActor & ClientLifecycleEventReceiver)
var _tcp_connection: TCPConnection = TCPConnection.none()
let _sslctx: SSLContext val
new create(auth: TCPConnectAuth, sslctx: SSLContext val,
host: String, port: String)
=>
_sslctx = sslctx
_tcp_connection = TCPConnection.client(auth, host, port, "", this, this)
fun ref _connection(): TCPConnection => _tcp_connection
fun ref _on_connected() =>
// Negotiate upgrade over plaintext
_tcp_connection.send("STARTTLS")
fun ref _on_received(data: Array[U8] iso) =>
if String.from_array(consume data) == "OK" then
// Server agreed — initiate TLS handshake
_tcp_connection.start_tls(_sslctx, "localhost")
end
fun ref _on_tls_ready() =>
// Handshake complete — connection is now encrypted
_tcp_connection.send("encrypted payload")
fun ref _on_tls_failure() =>
// Handshake failed — _on_closed will follow
Nonestart_tls() returns None when the handshake has been started, or a StartTLSError if the upgrade cannot proceed (connection not open, already TLS, muted, buffered read data, or pending writes). During the handshake, send() returns SendErrorNotConnected. When the handshake completes, _on_tls_ready() fires. If it fails, _on_tls_failure() fires followed by _on_closed().
[0.7.2] - 2026-02-10
Added
- Add TLS upgrade support (STARTTLS) (PR #171)
0.7.1
Fix SSL host verification not disabled by set_client_verify(false)
We've updated the ponylang/ssl dependency to 1.0.2 to pick up a bug fix for SSL host verification.
When set_client_verify(false) was called on an SSLContext, peer certificate verification was correctly disabled, but hostname verification still ran when a hostname was passed to SSLContext.client(hostname). This meant connections would fail if the server certificate didn't have a SAN or CN matching the hostname, even with verification explicitly disabled.
Hostname verification is now correctly skipped when set_client_verify(false) is set.
[0.7.1] - 2026-02-10
Fixed
- Fix SSL host verification not disabled by set_client_verify(false) (PR #169)