This repository was archived by the owner on Sep 11, 2026. It is now read-only.
Repository navigation
Explicit Connection Lifecycle States for TCPConnection #219
Closed
SeanTAllen
started this conversation in
Research
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Explicit Connection Lifecycle States for TCPConnection
Problem
TCPConnectionencodes its connection lifecycle as boolean flags:_connected,_closed,_shutdown,_shutdown_peer, plus SSL-related flags_ssl_ready,_ssl_failed,_tls_upgrade,_ssl_auth_failed. These flags form an implicit state machine where valid combinations aren't documented and invalid combinations aren't prevented.This creates several maintenance challenges:
Dense dispatch in
_event_notify: The method is ~95 lines with nested branching onevent is _event,not _connected and not _closed, and platformifdefblocks. Different lifecycle phases (client doing Happy Eyeballs, open connection doing I/O, connection shutting down) are interleaved in the same method.Complex callback routing in
hard_close(): Which lifecycle callback fires depends on a multi-way branch: SSL present? SSL ready? TLS upgrade? Client or server? Auth failed? These checks inspect 5+ flags to determine the correct callback. The method is ~118 lines.Scattered state transitions:
_connected = trueis set in_event_notify(Happy Eyeballs success) and_complete_server_initialization._closed = trueis set inhard_close()(both the connecting and connected branches),_close(), and_complete_server_initialization. Understanding the lifecycle requires tracing all flag mutations across a 1644-line file.Inspiration: ponylang/postgres
The ponylang/postgres library addresses the same class of problem with state objects: each lifecycle state is a separate class, and the
Sessionactor holds a singlevar state: _SessionStatefield. Every incoming event is delegated to whatever state object is current. The session actor itself contains no conditional logic about what state it's in — it is purely a dispatcher.A trait hierarchy provides defaults for operations that are illegal in each state. Capability traits implement valid behavior; denial traits panic on impossible transitions. Each concrete state mixes in the appropriate combination, so it only implements the methods that are valid transitions. Everything else panics by default.
This pattern also ties data ownership to state — per-query accumulation data lives inside the in-flight query state object and is structurally destroyed on state transition.
Analysis: Which of Lori's state machines benefit?
TCPConnection has six interleaved concerns:
_connected,_closed,_shutdown,_shutdown_peer_ssl,_ssl_ready,_ssl_failed,_tls_upgrade,_ssl_auth_failed_throttled,_writeable_readable,_muted,_yield_read_inflight_connections,_had_inflight_timer_event,_idle_timeout_nsecRead flow was evaluated as a candidate.
_readableis checked in exactly one place — the_read()loop condition._yield_readis a one-shot flag cleared by the yield check._mutedsuppresses reads but doesn't make operations structurally illegal. All three remain as flags.Idle timeout adds a timer event handled at the top of
_event_notify, but the lifecycle is straightforward: arm when connected, reset on I/O activity, cancel on close. No complex state transitions.Conclusion: The connection lifecycle is the right starting point. It's where operations become structurally illegal (you can't send on a closed connection), where dispatch is most complex (
_event_notify,hard_close()), and where flag combinations are hardest to reason about.Proposed Design
States
_ConnectionNone: The universal initial state. All constructors (includingnone()) start here. No behaviors can fire between construction and_finish_initialization(it's the first behavior queued), so_ConnectionNoneis safe as the starting state. When_finish_initializationfires, it transitions to the appropriate state. ForTCPConnection.none()(the field initializer placeholder),_finish_initializationis never called because the actor's constructor body replaces the placeholder before any behaviors fire. Every operation on_ConnectionNoneis_Unreachable()._ClientConnecting: Happy Eyeballs in progress. Handles incoming connection-attempt events viaforeign_event(). Contains the_connecting_callback()logic — firing_on_connecting(count)as attempts resolve.send()returnsSendErrorNotConnected.start_tls()returnsStartTLSNotConnected.When
hard_close()is called during connecting, it marks the connection closed, disposes SSL if present, fires_on_connection_failure()with the appropriate reason (ConnectionFailedDNSorConnectionFailedTCPbased on_had_inflight), cancels the idle timer, and transitions to_Closed. Straggler events arriving afterhard_close()are cleaned up by_Closed.When
close()is called during connecting (and not muted), a_pending_closeflag is set. Subsequent Happy Eyeballs events go to straggler cleanup instead of attempting to establish a connection. When all inflight connections have drained,_on_connection_failure()fires and the state transitions to_Closed. (The_pending_closeflag is a localized boolean within_ClientConnecting— it introduces a mini implicit state within this one state class. The trade-off is acceptable: splitting_ClientConnectinginto_ClientConnectingand_ClientConnectingClosedwould double the connecting logic for a simple binary choice.)When all connection attempts fail naturally (without explicit close/hard_close),
_connecting_callback()callshard_close(), which handles the cleanup and transition to_Closed._Open: Connected and alive. Handles own-event I/O viaown_event(), send, close, start_tls. Straggler Happy Eyeballs events (connection attempts that resolved after we picked a winner) are cleaned up inforeign_event(). SSL handshake progress and flow control flags operate within this state._Closing: Graceful close initiated. No new sends accepted (is_open()returns false). Sends FIN viaPonyTCP.shutdown()once inflight stragglers have drained (_inflight_connections == 0). Continues reading to detect peer FIN and handling write completions. Transitions to_Closedviahard_close()when both local and peer shutdown are complete._Closed: Terminal.send()returnsSendErrorNotConnected.close()andhard_close()are no-ops. Straggler events are cleaned up (unsubscribe, close fd, destroy).is_open()/is_closed()semantics:is_open()means "the connection can accept application operations (send, start_tls)."is_closed()means "the connection is no longer accepting application operations." These are not exact complements — during_ClientConnecting, both returnfalse._Closingreturnsis_open() = false,is_closed() = true, matching current behavior where_closed = trueafter_close(). This matters because_register_spawnerchecksis_closed()to decide whether to store the spawner or immediately notify the listener.is_open()is_closed()_ConnectionNonefalsefalse_ClientConnectingfalsefalse_Opentruefalse_Closingfalsetrue_ClosedfalsetrueClient/server dimension
Currently encoded as the type of
_lifecycle_event_receiver(ClientLifecycleEventReceivervsServerLifecycleEventReceiver). The_ClientConnectingstate is inherently client-only. Within_Openand_Closing, the client/server distinction only affects callback names (_on_connectedvs_on_started,_on_connection_failurevs_on_start_failure). State objects match on the receiver type for these differences. No_ClientOpen/_ServerOpensplit — it would duplicate nearly identical classes for a small callback routing difference.Server initialization
Servers receive an fd from the listener and transition directly to
_Openduring_complete_server_initialization. If SSL session creation failed in the constructor (_ssl_failed), the server transitions to_Closedinstead, closing the fd and firing_on_start_failure(StartFailedSSL).Initialization dispatch
_finish_initialization()is a behavior onTCPConnectionActorthat fires asynchronously after construction. All constructors (includingnone()) start with_state = _ConnectionNone. No behaviors can fire between construction and_finish_initialization(it's the first behavior queued), so_ConnectionNoneis safe as the universal initial state.When
_finish_initializationfires, TCPConnection's_finish_initialization()method performs the transition:_ssl_failed(transitions to_Closedwith_on_connection_failure(ConnectionFailedSSL)if true). Otherwise transitions to_ClientConnectingand callsPonyTCP.connect()to initiate Happy Eyeballs. Note: the current code does not set_closed = trueon client SSL failure (unlike the server path, which does). The state-object design fixes this — both paths transition to_Closed._ssl_failed(transitions to_Closedwith_on_start_failure(StartFailedSSL)if true). Otherwise transitions to_Open, creates the ASIO event, arms the idle timer (if configured), and starts reading.The
_finish_initializationmethod itself remains on TCPConnection — it's not dispatched through the state interface. It's the bootstrap that sets the first real state.For
TCPConnection.none()(the field initializer placeholder),_finish_initializationis never called because the actor's constructor body replaces the placeholder with a realTCPConnectionbefore any behaviors fire.State interface
_event_notifydispatchTCPConnection's
_event_notifyhandles timer events and event ownership dispatch before delegating lifecycle-relevant events to the state:Disposable handling is uniform across all states, so it stays on TCPConnection as a post-dispatch step rather than being duplicated in every state. Placing it after state dispatch (not before with a
return) ensures that readable/writeable flags in the same notification are still processed by the state first.This replaces the current nested
if event is _event/if not _connected and not _closedstructure with per-state dispatch:_ClientConnecting.foreign_event(): Happy Eyeballs resolution — the currentif not _connected and not _closedblock. When_pending_closeis set, routes to straggler cleanup instead of connection establishment._Open.own_event(): Normal I/O — the currentif event is _eventblock for an established connection._Open.foreign_event(): Straggler Happy Eyeballs cleanup (unsubscribe, close fd)._Closing.own_event(): I/O with shutdown tracking. Same as_Openbut reads may complete the shutdown sequence._Closing.foreign_event(): Straggler cleanup. Stragglers are asynchronous and can arrive afterclose()._Closed.own_event()/foreign_event(): Straggler cleanup only (unsubscribe, close fd, destroy disposable events). Afterhard_close(), the main event has been unsubscribed and the fd closed, but stragglers and disposable notifications may still arrive._ConnectionNone:_Unreachable()for both — no events can arrive before_finish_initialization._try_shutdown()is absorbed into_Closing— it only does meaningful work in that state (in all others it early-returns becauseis_closed()is false)._Closing.own_event()and_Closing.foreign_event()call the shutdown logic after handling I/O or straggler cleanup. The connecting-phase close branch that currently lives in_try_shutdown()(if _connected then ... else ...) is handled by_ClientConnecting._pending_closeinstead, so it doesn't migrate to_Closing.Asynchronous operations and state safety
Several behaviors on
TCPConnectionActordispatch toTCPConnectionmethods that are state-dependent:_read_again: Calls_read()(POSIX) or_windows_resume_read()(Windows) to re-enter the read loop. Can arrive after the connection has closed (it's asynchronous). Currently,_read()on a closed connection exits immediately —hard_close()sets_readable = falseand_shutdown_peer = true, so the outer loop conditionwhile _readable and not _shutdown_peeris false._windows_resume_read()guards withif not _connected then return end. Both guards work but rely on flags that the state machine replaces. With state objects,_read_againdispatches through the state interface viaread_again()—_Closedno-ops,_Open/_Closingcall the platform-specific read method._notify_sent/_notify_send_failed: These fire deferred callbacks via_fire_on_sent()/_fire_on_send_failed()on TCPConnection. They can arrive after close and must still deliver their callbacks (by design —_on_send_failedis expected after_on_closed). They remain on TCPConnection, not gated by state._register_spawner: Server-only. Currently gates on_closed. With state objects, it can use_state.is_closed()directly on TCPConnection without being part of the state interface.Other state-dependent methods
keepalive()currently gates on_connected(a minor inconsistency with the other methods that useis_open()). With state objects, it gates on_state.is_open(), making it consistent. It remains on TCPConnection — not part of the state interface.Socket options (
set_nodelay(),get_so_rcvbuf(),set_so_rcvbuf(),get_so_sndbuf(),set_so_sndbuf()) currently gate onis_open(). Same treatment askeepalive()— they stay on TCPConnection, gating via_state.is_open().idle_timeout()currently gates on_connected and not _closed. With state objects, it gates on_state.is_open(). The timer arm/reset/cancel methods remain on TCPConnection, with arm points in initialization and cancel inhard_close().Read buffer methods (
set_read_buffer_minimum(),resize_read_buffer(),expect()) are not state-gated — they can be called anytime. They stay on TCPConnection unchanged.close()and the mute checkclose()currently checks_mutedand delegates tohard_close()(if muted) or_close()(if not). This check stays on TCPConnection as a pre-dispatch step — it's the same in every state and doesn't need per-state logic:Each state's
close()only handles the graceful (non-muted) path._Opentransitions to_Closing._ClientConnectingsets_pending_close._Closingand_Closedno-op.hard_close()is dispatched through the state interface separately.Spawner notification
Server connections notify their spawning listener via
spawner._connection_closed()during teardown (currently at the end ofhard_close()). This stays in thehard_close()implementations on_Openand_Closing, since it only applies when a connected server connection closes._ClientConnecting.hard_close()and_Closed.hard_close()don't need it.State transition ordering
hard_close()currently sets_connected = falseand_closed = trueat the top (before firing any callbacks). The state-object equivalent must preserve this: set_state = _Closedbefore firing_on_closed(),_on_connection_failure(), etc. This ensures that if a callback re-enters TCPConnection (e.g., callingsend()), it sees the closed state and gets the appropriate error.Data ownership
Recommended: states are logic-only. All data (
_fd,_event,_read_buffer,_pending_data, SSL fields, flow control flags, idle timeout fields, client startup fields) stays onTCPConnection. State objects provide dispatch and operation gating. Transitions are_state = _Openrather than flag mutations.The alternative — states owning per-state data — was considered:
_ClientConnectingcould own_host,_port,_from,_ip_version,_inflight_connections,_had_inflight(only relevant while connecting). But these are set in the constructor and consumed in_finish_initialization— they'd need to survive the constructor-to-behavior gap on TCPConnection regardless and then be passed to_ClientConnecting's constructor._Closingcould own_shutdown,_shutdown_peer(only relevant during graceful close). But_Openand_Closingshare almost all other data (fd, event, buffers, SSL, idle timeout), making ownership between them awkward.Data ownership can be added incrementally later where it proves valuable. The first iteration focuses on making lifecycle transitions explicit.
Platform differences (IOCP)
POSIX and IOCP have different I/O models (edge-triggered notification vs completion callbacks) but the same state transitions. The platform difference is in how I/O is performed within a state, not which states exist.
With logic-only state objects, platform-specific I/O methods stay on TCPConnection where they are today (
_read()for POSIX,_read_completed()and_windows_resume_read()for Windows). State objects call into these methods, and theifdefblocks remain in the I/O layer.SSL
SSL remains as fields on TCPConnection for this iteration. The
_ssl,_ssl_ready,_ssl_failed,_tls_upgrade,_ssl_auth_failed,_ssl_expectfields and associated logic in_ssl_poll()stay unchanged.A future iteration could model SSL as a sub-state machine within
_Open(e.g.,NoSSL | SSLHandshaking | SSLReady). This would simplify the callback routing inhard_close()— the SSL sub-state would know whether to fire_on_connection_failure,_on_tls_failure, or_on_closed. But that's a separate step that builds on the foundation this change lays.Trait hierarchy
The postgres library uses a rich hierarchy of capability traits and denial traits. This pays off when there are ~20 methods and many states need the same defaults.
For Lori's smaller surface (~8 methods, 5 states), each state can directly implement the full interface. Illegal operations are one-liners:
If the method count grows (e.g., when SSL becomes a sub-state machine), a trait hierarchy can be introduced then.
What changes
_event_notifysplits from one ~95-line method into per-state handlers, each handling only its relevant events. Timer handling stays in TCPConnection as a pre-dispatch step.send(),close(),hard_close(),start_tls()gate on state type rather than boolean flag checks._state = _Open) rather than scattered flag mutations.hard_close()callback routing is partially simplified — the state knows whether we're in the connecting phase, open, or closing._read_againdispatches through the state interface viaread_again(), preventing reads on closed connections.close()during connecting is handled by_ClientConnectingwith a_pending_closeflag rather than by setting_closed = trueand relying on_try_shutdown()to notice.What stays the same
_readable,_writeable,_throttled,_muted,_yield_read) remain on TCPConnection._read,_read_completed,_windows_resume_read,_send_pending_writes, etc.) remain on TCPConnection.set_nodelay, buffer sizes) remain on TCPConnection._try_shutdown()is absorbed into_Closing(see_event_notifydispatch section)._read_againbehavior body changes to dispatch through the state interface instead of calling_read()/_windows_resume_read()directly, but the behavior's public signature stays the same.Pony implementation notes
State objects must be
class(notprimitive) because the_ConnectionStatetrait usesfun refmethods. While states like_Closedand_ConnectionNonehave no per-instance data and could theoretically be primitives, primitives areval— they can't satisfyfun refmethod signatures. Since the trait needs uniformity across all states (and_ClientConnectingneedsreffor_pending_close), all states useclass. The allocation cost is minimal — state transitions are infrequent relative to I/O operations.All reactions