Releases: ponylang/lori
0.14.1
Add timer ASIO subscription failure callbacks
Previously, when the idle timer or a user timer's ASIO event subscription failed (e.g. ENOMEM from the kernel's kevent or epoll_ctl), lori would silently cancel the timer with no notification. The connection would keep running without the protection the application had configured. There was no way for the application to know the timer subsystem had failed or to retry.
Two new lifecycle callbacks now surface these failures.
_on_idle_timer_failure() fires when the idle timer's ASIO subscription fails. Before the callback runs, the idle timer has been cancelled and its duration cleared. The connection continues to run — the application decides whether to re-arm via idle_timeout(duration), close the connection, or take some other action.
_on_timer_failure() fires when a user timer's ASIO subscription fails. Before the callback runs, the user timer has been cancelled and the token cleared. As with the idle failure callback, the application decides how to recover — call set_timer(duration) to create a new timer, close the connection, etc.
Both callbacks have default no-op implementations, so applications that don't override them keep the current silent-cancel behavior.
actor MyConnection is (TCPConnectionActor & ClientLifecycleEventReceiver)
// ...
fun ref _on_idle_timer_failure() =>
// Idle detection is off. Try to bring it back up.
match MakeIdleTimeout(30_000)
| let t: IdleTimeout => _tcp_connection.idle_timeout(t)
end
fun ref _on_timer_failure() =>
// The query timer never armed. Give up on this request.
_tcp_connection.close()Connect timers are unaffected — their ASIO subscription failures already route to _on_connection_failure(ConnectionFailedTimerError).
[0.14.1] - 2026-04-14
Fixed
- Add timer ASIO subscription failure callbacks (PR #281)
0.14.0
Handle ASIO_ERROR notifications from ponyc runtime
ponyc 0.63.1 introduced ASIO_ERROR notifications that fire when an event subscription fails (e.g. kqueue/epoll returns ENOMEM). Without handling, these notifications were silently dropped, potentially leaving connections or listeners in a stuck state.
Lori now handles ASIO_ERROR across all event categories:
- TCPListener: Closes the listener when its event subscription fails.
- TCPConnection own events: Hard-closes the connection when its socket event subscription fails.
- TCPConnection foreign events: Treats errored Happy Eyeballs events as failed connection attempts during connecting, or as stragglers in other states.
- TCPConnection timer events: Connect timer errors abort the connection with
ConnectionFailedTimerError. Idle timer and user timer errors cancel the timer silently.
A new ConnectionFailedTimerError failure reason has been added to ConnectionFailureReason. If you match exhaustively on ConnectionFailureReason, you'll need to add a case for the new variant:
match \exhaustive\ reason
| ConnectionFailedDNS => // ...
| ConnectionFailedTCP => // ...
| ConnectionFailedSSL => // ...
| ConnectionFailedTimeout => // ...
| ConnectionFailedTimerError => // ...
endRequires ponyc 0.63.1 or later.
[0.14.0] - 2026-04-12
Added
- Handle ASIO_ERROR notifications from ponyc runtime (PR #280)
Changed
- Add
ConnectionFailedTimerErrortoConnectionFailureReasontype alias (PR #280)
0.13.1
Fix connection stall after large write with backpressure
A connection could stop processing incoming data after completing a large write that triggered backpressure. The connection would hang until either side closed it.
[0.13.1] - 2026-04-07
Fixed
- Fix connection stall after large write with backpressure (PR #278)
0.13.0
Use prebuilt LibreSSL binaries on Windows
The libs command has been removed from make.ps1. CI now downloads prebuilt LibreSSL static libraries directly from the LibreSSL GitHub releases instead of building from source. Windows users who were using make.ps1 -Command libs to build LibreSSL locally can download prebuilt binaries from the same location. Prebuilt binaries are available for x86-64 and ARM64.
Fix crash when dispose() arrives before connection initialization
Calling dispose() on a connection actor before its internal initialization completed would crash with an unreachable state error. This could happen because _finish_initialization is a self-to-self message queued during the actor's constructor, while dispose() arrives from an external actor — and Pony's causal messaging provides no ordering guarantee between different senders. The race is unlikely but was observed on macOS arm64 CI.
[0.13.0] - 2026-03-28
Fixed
- Fix crash when dispose() arrives before connection initialization (PR #271)
Changed
- Use prebuilt LibreSSL binaries on Windows (PR #263)
0.12.0
Add optional connection timeout for client connections
Client connection attempts can now be bounded with a timeout that covers the TCP Happy Eyeballs phase and (for SSL connections) the TLS handshake. Pass a ConnectionTimeout to the client or ssl_client constructor:
match MakeConnectionTimeout(5_000)
| let ct: ConnectionTimeout =>
_tcp_connection = TCPConnection.client(auth, host, port, "", this, this
where connection_timeout = ct)
endIf the timeout fires before _on_connected, the connection fails with ConnectionFailedTimeout in _on_connection_failure. The timeout is disabled by default (None).
Expand ConnectionFailureReason with ConnectionFailedTimeout
ConnectionFailureReason now includes ConnectionFailedTimeout. This is a breaking change — exhaustive matches on ConnectionFailureReason must add a branch for the new variant:
Before:
match reason
| ConnectionFailedDNS => // ...
| ConnectionFailedTCP => // ...
| ConnectionFailedSSL => // ...
endAfter:
match reason
| ConnectionFailedDNS => // ...
| ConnectionFailedTCP => // ...
| ConnectionFailedSSL => // ...
| ConnectionFailedTimeout => // ...
endFix idle timer issues with SSL connections
The idle timer had two issues with SSL connections:
The timer was being armed when the TCP connection established, before the SSL handshake completed. If an idle timeout was configured before the connection was ready, _on_idle_timeout() could fire before _on_connected() or _on_started().
Calling idle_timeout() on an SSL connection during the handshake could also arm the timer prematurely, producing the same early _on_idle_timeout(). Additionally, when the handshake later completed, a second timer was created — leaking the first ASIO timer event.
The idle timer now defers arming until the SSL handshake completes, regardless of whether the timeout is configured before or during the handshake.
Add general-purpose one-shot timer
set_timer() creates a one-shot timer on a connection that fires _on_timer() after a configured duration. Unlike idle_timeout(), this timer fires unconditionally — it is not reset by send/receive activity. This is the building block for application-level timeouts like query deadlines where I/O activity should not postpone the timeout.
fun ref _on_connected() =>
_tcp_connection.send("SELECT * FROM big_table")
match MakeTimerDuration(10_000)
| let d: TimerDuration =>
match _tcp_connection.set_timer(d)
| let t: TimerToken => _query_timer = t
end
end
fun ref _on_received(data: Array[U8] iso) =>
match _query_timer
| let t: TimerToken =>
_tcp_connection.cancel_timer(t)
_query_timer = None
end
// process response...
fun ref _on_timer(token: TimerToken) =>
// query timed out
_tcp_connection.close()Only one timer can be active at a time. Setting a timer while one is active returns SetTimerAlreadyActive — cancel the existing timer first. The timer survives close() (graceful shutdown) but is cancelled by hard_close(). There is no automatic re-arming; call set_timer() again from _on_timer() for repetition.
Fix resource leak from orphaned Happy Eyeballs connections
When close() or hard_close() was called during the connecting phase, inflight Happy Eyeballs connection attempts could leak file descriptors and ASIO events. On Linux, failed connection attempts delivered error-only events (ASIO_READ without ASIO_WRITE) that were silently dropped by the writeable guard, preventing cleanup. On macOS, failed sockets produced two events per socket; the old guard accidentally filtered one, but the cleanup still had gaps.
Inflight connections are now reliably drained on all platforms.
Rename expect() to buffer_until() with clearer type names
The expect() method on TCPConnection has been renamed to buffer_until() to better convey its semantics: "buffer data until you have this many bytes, then deliver." The None sentinel that meant "deliver all available data" is replaced by an explicit Streaming primitive.
Before:
match MakeExpect(4)
| let e: Expect => _tcp_connection.expect(e)
end
// streaming mode
_tcp_connection.expect(None)After:
match MakeBufferSize(4)
| let e: BufferSize => _tcp_connection.buffer_until(e)
end
// streaming mode
_tcp_connection.buffer_until(Streaming)Full rename mapping:
| Old | New |
|---|---|
expect(qty) |
buffer_until(qty) |
Expect |
BufferSize |
MakeExpect |
MakeBufferSize |
None (in expect context) |
Streaming |
ExpectSet |
BufferUntilSet |
ExpectAboveBufferMinimum |
BufferSizeAboveMinimum |
ExpectResult |
BufferUntilResult |
ReadBufferResizeBelowExpect |
ReadBufferResizeBelowBufferSize |
[0.12.0] - 2026-03-22
Fixed
- Fix idle timer issues with SSL connections (PR #238)
- Fix resource leak from orphaned Happy Eyeballs connections (PR #247)
Added
- Add optional connection timeout for client connections (PR #236)
- Add general-purpose one-shot timer (PR #241)
Changed
0.11.0
Add configurable read buffer size
TCPConnection now accepts a read_buffer_size constructor parameter (default 16KB) to set the initial buffer allocation and shrink-back minimum. The parameter takes a ReadBufferSize constrained type that guarantees a value of at least 1. Two new runtime methods let you tune the buffer after construction:
set_read_buffer_minimum(size)— sets the floor the buffer shrinks to when emptyresize_read_buffer(size)— forces the buffer to an exact size, reallocating immediately
match MakeReadBufferSize(128)
| let rbs: ReadBufferSize =>
_tcp_connection = TCPConnection.server(auth, fd, this, this
where read_buffer_size = rbs)
end
// Later, switch to a larger buffer for bulk transfer
match MakeReadBufferSize(8192)
| let rbs: ReadBufferSize =>
_tcp_connection.set_read_buffer_minimum(rbs)
_tcp_connection.resize_read_buffer(rbs)
endThe invariant chain expect <= read_buffer_min <= read_buffer_size is enforced by all three APIs. Each returns a result type indicating success or the specific constraint violation.
Change expect() to return ExpectResult instead of raising an error
expect() previously raised an error when the requested value exceeded the buffer size. It now returns ExpectResult, which is either ExpectSet or ExpectAboveBufferMinimum. This is a breaking change — all callers using try expect()? end must switch to matching on the result.
Before:
try _tcp_connection.expect(4)? endAfter:
match MakeExpect(4)
| let e: Expect => _tcp_connection.expect(e)
endThe guard now checks against the read buffer minimum rather than the buffer size, enforcing the expect <= read_buffer_min invariant.
Make expect() use a constrained type instead of raw USize
expect() now takes (Expect | None) instead of USize. Expect is a constrained type that guarantees a value of at least 1. None replaces the magic value 0 to mean "deliver all available data." This follows the same pattern used by IdleTimeout, ReadBufferSize, and MaxSpawn.
Before:
_tcp_connection.expect(4)
_tcp_connection.expect(0)After:
match MakeExpect(4)
| let e: Expect => _tcp_connection.expect(e)
end
_tcp_connection.expect(None)Add TCP_NODELAY and socket buffer size methods
TCPConnection now exposes five methods for commonly-tuned socket options on connected sockets:
set_nodelay(state)— enable/disable Nagle's algorithm (TCP_NODELAY)set_so_rcvbuf(bufsize)/get_so_rcvbuf()— set/get the OS receive buffer sizeset_so_sndbuf(bufsize)/get_so_sndbuf()— set/get the OS send buffer size
All setters return 0 on success or a non-zero errno on failure. Getters return (errno, value). All methods require a connected socket — they return a non-zero error indicator when the connection is not open.
fun ref _on_started() =>
_tcp_connection.set_nodelay(true)
_tcp_connection.set_so_rcvbuf(65536)
_tcp_connection.set_so_sndbuf(65536)
(let errno: U32, let rcvbuf: U32) = _tcp_connection.get_so_rcvbuf()Add general socket option access
TCPConnection now exposes general-purpose getsockopt/setsockopt methods for accessing any socket option, not just the ones with dedicated convenience methods:
getsockopt(level, option_name, option_max_size)— raw bytes interface togetsockopt(2)getsockopt_u32(level, option_name)— convenience wrapper when the option value is aU32setsockopt(level, option_name, option)— raw bytes interface tosetsockopt(2)setsockopt_u32(level, option_name, option)— convenience wrapper when the option value is aU32
All methods require a connected socket and return errno-based results matching the existing convenience methods. Use OSSockOpt constants for the level and option_name parameters.
// Set TCP_KEEPIDLE via the general-purpose interface
_tcp_connection.setsockopt_u32(
OSSockOpt.ipproto_tcp(), OSSockOpt.tcp_keepidle(), 60)
// Read back a socket option as raw bytes
(let errno: U32, let data: Array[U8] iso) =
_tcp_connection.getsockopt(
OSSockOpt.sol_socket(), OSSockOpt.so_rcvbuf())For commonly-tuned options (TCP_NODELAY, SO_RCVBUF, SO_SNDBUF), the dedicated convenience methods remain the preferred interface.
Fix dispose() hanging when peer FIN is missed
TCPConnectionActor.dispose() previously called close(), which does a graceful half-close — it sends a FIN and waits for the peer to acknowledge before fully cleaning up. On POSIX with edge-triggered oneshot events, the peer's FIN notification can be missed in a narrow timing window after resubscription, leaving the socket stuck in CLOSE_WAIT and preventing the runtime from exiting.
dispose() now calls hard_close(), which immediately unsubscribes from ASIO, closes the fd, and fires _on_closed(). This matches what callers expect from disposal: unconditional teardown, not a protocol exchange. Applications that need a graceful close should call close() explicitly before disposal.
[0.11.0] - 2026-03-15
Fixed
- Fix dispose() hanging when peer FIN is missed (PR #230)
Added
- Add configurable read buffer size (PR #214)
- Add TCP_NODELAY and socket buffer size methods (PR #217)
- Add general socket option access (PR #221)
Changed
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)