Skip to content

Lori Package

Lori is a TCP networking library that separates connection logic from actor scheduling. Unlike the standard library's net package, which bakes connection handling into a single actor, lori puts the TCP state machine in a plain TCPConnection class that your actor delegates to. This separation gives you control over how your actor is structured while lori handles the low-level I/O.

To build a TCP application with lori, you implement an actor that mixes in two traits: TCPConnectionActor (which wires up the ASIO event plumbing) and a lifecycle event receiver (ServerLifecycleEventReceiver or ClientLifecycleEventReceiver) that delivers callbacks like _on_received, _on_connected, and _on_closed.

Echo Server

Here is a complete echo server. It has two actors: a listener that accepts connections and a connection handler that echoes data back to the client.

use "lori"

actor Main
  new create(env: Env) =>
    EchoServer(TCPListenAuth(env.root), "", "7669", env.out)

actor EchoServer is TCPListenerActor
  var _tcp_listener: TCPListener = TCPListener.none()
  let _out: OutStream
  let _server_auth: TCPServerAuth

  new create(listen_auth: TCPListenAuth,
    host: String,
    port: String,
    out: OutStream)
  =>
    _out = out
    _server_auth = TCPServerAuth(listen_auth)
    _tcp_listener = TCPListener(listen_auth, host, port, this)

  fun ref _listener(): TCPListener =>
    _tcp_listener

  fun ref _on_accept(fd: U32): Echoer =>
    Echoer(_server_auth, fd, _out)

  fun ref _on_listening() =>
    _out.print("Echo server started.")

  fun ref _on_listen_failure() =>
    _out.print("Couldn't start Echo server.")

actor Echoer is (TCPConnectionActor & ServerLifecycleEventReceiver)
  var _tcp_connection: TCPConnection = TCPConnection.none()
  let _out: OutStream

  new create(auth: TCPServerAuth, fd: U32, out: OutStream) =>
    _out = out
    _tcp_connection = TCPConnection.server(auth, fd, this, this)

  fun ref _connection(): TCPConnection =>
    _tcp_connection

  fun ref _on_received(data: Array[U8] iso) =>
    _tcp_connection.send(consume data)

  fun ref _on_closed() =>
    _out.print("Connection closed.")

The listener actor implements TCPListenerActor. It owns a TCPListener and must provide _listener() to return it. When a client connects, _on_accept is called with the raw file descriptor. You create and return a connection-handling actor from there.

The connection handler implements both TCPConnectionActor and ServerLifecycleEventReceiver. It owns a TCPConnection and must provide _connection() to return it. Data arrives via _on_received.

Note the TCPConnection.none() and TCPListener.none() field initializers. Pony requires fields to be initialized before the constructor body runs, but the real connection setup happens asynchronously. The none() constructors provide safe placeholder values that are replaced by real initialization via the _finish_initialization behavior.

Client

Here is a client that connects to a server and sends a message:

use "lori"

actor MyClient is (TCPConnectionActor & ClientLifecycleEventReceiver)
  var _tcp_connection: TCPConnection = TCPConnection.none()

  new create(auth: TCPConnectAuth, host: String, port: String) =>
    _tcp_connection = TCPConnection.client(auth, host, port, "", this, this)

  fun ref _connection(): TCPConnection =>
    _tcp_connection

  fun ref _on_connected() =>
    _tcp_connection.send("Hello, server!")

  fun ref _on_connection_failure(reason: ConnectionFailureReason) =>
    // All connection attempts failed
    None

  fun ref _on_received(data: Array[U8] iso) =>
    // Handle response from server
    None

Clients use ClientLifecycleEventReceiver instead of ServerLifecycleEventReceiver. The key difference is the connection lifecycle: clients get _on_connecting (called as connection attempts are in progress), _on_connected (ready for data), and _on_connection_failure (all attempts failed, with a ConnectionFailureReason indicating the failure stage). Servers get _on_started (ready for data) and _on_start_failure.

Sending Data

Unlike many networking libraries, send() is fallible. It returns (SendToken | SendError) rather than silently dropping data:

match _tcp_connection.send("some data")
| let token: SendToken =>
  // Data accepted. token will arrive in _on_sent when fully written.
  None
| SendErrorNotConnected =>
  // Connection is not open.
  None
| SendErrorNotWriteable =>
  // Under backpressure. Wait for _on_unthrottled before retrying.
  None
end

SendToken is an opaque value identifying the send operation. When the data has been fully handed to the OS, lori delivers the same token to _on_sent. If the connection closes while a write is still partially pending, _on_send_failed fires instead. Both callbacks always arrive in a subsequent behavior turn, never during send() itself.

The library does not queue data during backpressure. When send() returns SendErrorNotWriteable, the application decides what to do: queue, drop, or close. Use _on_throttled and _on_unthrottled to track backpressure state, or check is_writeable() before calling send().

send() accepts both a single buffer (ByteSeq) and multiple buffers (ByteSeqIter). When a protocol sends structured data (e.g. a length header followed by a payload), passing multiple buffers sends them in a single writev syscall — avoiding both the per-buffer syscall overhead of calling send() multiple times and the cost of copying into a contiguous buffer:

// Single buffer
_tcp_connection.send("Hello, world!")

// Multiple buffers — one writev syscall
let header: Array[U8] val = _encode_header(payload.size())
_tcp_connection.send(recover val [as ByteSeq: header; payload] end)

SSL

Adding SSL to a connection requires only a constructor change. Use TCPConnection.ssl_client or TCPConnection.ssl_server with an SSLContext val:

use "lori"
use "ssl/net"

actor SSLEchoer is (TCPConnectionActor & ServerLifecycleEventReceiver)
  var _tcp_connection: TCPConnection = TCPConnection.none()

  new create(auth: TCPServerAuth, sslctx: SSLContext val, fd: U32) =>
    _tcp_connection = TCPConnection.ssl_server(auth, sslctx, fd, this, this)

  fun ref _connection(): TCPConnection =>
    _tcp_connection

  fun ref _on_received(data: Array[U8] iso) =>
    _tcp_connection.send(consume data)

  fun ref _on_start_failure(reason: StartFailureReason) =>
    // SSL handshake failed
    None

SSL is handled entirely inside TCPConnection. The handshake runs transparently after the TCP connection is established, and _on_connected (client) or _on_started (server) fires only after the handshake completes. If the handshake fails, clients get _on_connection_failure (with ConnectionFailedSSL) and servers get _on_start_failure (with StartFailedSSL). The rest of the application code (sending, receiving, closing) is identical to the non-SSL case.

TLS Upgrade (STARTTLS)

Some protocols (PostgreSQL, SMTP, LDAP) require upgrading an existing plaintext connection to TLS mid-stream. Use start_tls() on an established connection to initiate a TLS handshake:

use "lori"
use "ssl/net"

actor MyStartTLSClient 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() =>
    // Send protocol-specific upgrade request over plaintext
    _tcp_connection.send("STARTTLS")

  fun ref _on_received(data: Array[U8] iso) =>
    let msg = String.from_array(consume data)
    if msg == "OK" then
      // Server agreed to upgrade — initiate TLS handshake
      match _tcp_connection.start_tls(_sslctx, "localhost")
      | let err: StartTLSError => None // handle error
      end
    end

  fun ref _on_tls_ready() =>
    // TLS handshake complete — now sending encrypted data
    _tcp_connection.send("encrypted payload")

  fun ref _on_tls_failure(reason: TLSFailureReason) =>
    // TLS handshake failed — _on_closed will follow
    None

start_tls() returns None when the handshake has been started, or a StartTLSError if the upgrade cannot proceed. The connection must be open, not already TLS, not muted, and have no 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 (with a TLSFailureReason distinguishing authentication errors from protocol errors) followed by _on_closed().

Idle Timeout

idle_timeout() sets a per-connection timer that fires when no data is sent or received for the configured duration. Idle timeout is disabled by default. The duration is an IdleTimeout value — a constrained type that guarantees a millisecond value in the range 1 to 18,446,744,073,709. Pass None to disable:

fun ref _on_started() =>
  // Close connections idle for more than 30 seconds
  match MakeIdleTimeout(30_000)
  | let t: IdleTimeout =>
    _tcp_connection.idle_timeout(t)
  end

fun ref _on_idle_timeout() =>
  _tcp_connection.close()

The timer resets on every successful send() and every received data event. It automatically re-arms after each firing — the application decides what to do (close, send a keepalive, log, etc.). Call idle_timeout(None) to disable.

Idle timeout uses a per-connection ASIO timer event, requiring no extra actors or shared state. This avoids the muting-livelock problem that occurs with shared Timers actors under backpressure.

This is independent of TCP keepalive (keepalive()). TCP keepalive is a transport-level dead-peer probe. Idle timeout is application-level inactivity detection.

Read Yielding

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 — no explicit unmute() is needed.

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

Unlike mute()/unmute(), which persistently stop reading until reversed, yield_read() is a one-shot pause: the read loop resumes on its own. The application calls it from _on_received() and can implement any yield policy (message count, byte threshold, time-based, etc.).

For SSL connections, yield_read() operates at TCP-read granularity. All SSL-decrypted messages from a single TCP read are delivered before the yield takes effect.

Connection Limits

TCPListener accepts an optional limit parameter to cap the number of concurrent connections. The default limit is 100,000 connections (DefaultMaxSpawn). Pass None to disable the limit entirely:

// Use a custom limit
match MakeMaxSpawn(100)
| let limit: MaxSpawn =>
  _tcp_listener = TCPListener(listen_auth, host, port, this where limit = limit)
end

// No connection limit
_tcp_listener = TCPListener(listen_auth, host, port, this where limit = None)

When the limit is reached, the listener pauses accepting. As connections close, it resumes automatically.

IP Version

By default, lori uses dual-stack connections (both IPv4 and IPv6). To restrict a client or listener to a specific protocol version, pass an IPVersion parameter:

// IPv4-only listener
_tcp_listener = TCPListener(listen_auth, "127.0.0.1", "7669", this
  where ip_version = IP4)

// IPv6-only client
_tcp_connection = TCPConnection.client(auth, "::1", "7669", "", this, this
  where ip_version = IP6)

IP4 restricts to IPv4 only, IP6 restricts to IPv6 only, and DualStack (the default) allows both. The same parameter works on ssl_client:

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

Auth Hierarchy

Lori uses Pony's object capability model for authorization. Each operation requires a specific auth token, and tokens form a hierarchy — a more powerful token can create a less powerful one:

  • NetAuth (from AmbientAuth) — general network access
  • TCPAuth (from AmbientAuth or NetAuth) — any TCP operation
  • TCPListenAuth (from AmbientAuth, NetAuth, or TCPAuth) — open a listener
  • TCPConnectAuth (from AmbientAuth, NetAuth, or TCPAuth) — open a client connection
  • TCPServerAuth (from AmbientAuth, NetAuth, TCPAuth, or TCPListenAuth) — handle an accepted server connection

In practice, Main creates the auth tokens it needs from env.root and passes them to the actors that need them. The echo server example above shows the typical pattern: Main creates a TCPListenAuth, the listener creates a TCPServerAuth from it, and each accepted connection receives that TCPServerAuth.

Public Types