Repository navigation
Explicit Connection Lifecycle States for TCPConnection #174
Replies: 4 comments
Decision:
|
Decision:
|
Decision: Client/server dimension (Open Question 3)Decision: Keep client/server orthogonal to lifecycle state. The |
|
This is out of date. Closing. Will create a new one. |
Uh oh!
There was an error while loading. Please reload this page.
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. 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 ~90 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? These checks inspect 4+ flags to determine the correct callback.Scattered state transitions:
_connected = trueis set in_event_notify(Happy Eyeballs success) and_complete_server_initialization._closed = trueis set inhard_close(),_close(), and_complete_server_initialization. Understanding the lifecycle requires tracing all flag mutations across the 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 five interleaved state machines:
_connected,_closed,_shutdown,_shutdown_peer_ssl,_ssl_ready,_ssl_failed,_tls_upgrade_throttled,_writeable_readable,_muted_readableis a read loop condition, no operations are illegal when unreadable_inflight_connectionsReadable and writeable were evaluated as candidates.
_readableis checked in exactly one place — the_read()loop condition. No operation becomes illegal when the connection is unreadable._writeablehas slightly more gatekeeping (send()returnsSendErrorNotWriteable), but it's a transient flow-control flag, not a lifecycle boundary. Both remain as flags.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 (_ClientConnecting,_Open, or_Closed). 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. Contains the_connecting_callback()logic — firing_on_connecting(count)as attempts resolve, or_on_connection_failure()when all fail. Also handles the_ssl_failedcheck during_finish_initialization.send()returnsSendErrorNotConnected.start_tls()returnsStartTLSNotConnected._Open: Connected and alive. Handles own-event I/O, send, close, start_tls. Straggler Happy Eyeballs events (connection attempts that resolved after we picked a winner) are cleaned up here. SSL handshake and flow control flags live within this state._Closing: Graceful close initiated. No new sends accepted (is_open()returns false). Continues reading to detect peer shutdown. Drains pending writes. Contains the_try_shutdown()logic. Transitions to_Closedwhen both local shutdown and peer shutdown are complete._Closed: Terminal.send()returnsSendErrorNotConnected.close()andhard_close()are no-ops. Events are destroyed.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, firing_on_start_failure().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()if true). Otherwise transitions to_ClientConnectingand callsPonyTCP.connect()to initiate Happy Eyeballs._ssl_failed(transitions to_Closedwith_on_start_failure()if true). Otherwise transitions to_Open, creates the ASIO event, 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
TCPConnection's
_event_notifyhandles event ownership dispatch and disposable cleanup before delegating lifecycle-relevant events to the state:This replaces the current nested
if event is _event/if not _connected and not _closedstructure with per-state dispatch:_ClientConnecting.foreign_event()handles Happy Eyeballs resolution (the current innerif not _connected and not _closedblock)_Open.own_event()handles normal I/O (the currentif event is _eventblock)_Open.foreign_event()cleans up straggler Happy Eyeballs events_Closing.own_event()handles I/O with shutdown tracking_Closing.foreign_event()cleans up straggler Happy Eyeballs events (stragglers are asynchronous and can arrive afterclose())_Closedno-ops (events already handled by disposable check above)Asynchronous operations and state safety
Several behaviors on
TCPConnectionActordispatch toTCPConnectionmethods that are state-dependent:_read_again: Calls_connection()._read()to re-enter the read loop. Can arrive after the connection has closed (it's asynchronous). Currently there's no guard —_read()would try to read from fd-1. With state objects,_read_againshould dispatch through the state, which no-ops in_Closed. Similarly,unmute()calls_queue_read()which calls_read_again()— the same concern applies ifunmute()is called in a subsequent behavior after close._notify_sent/_notify_send_failed: These fire deferred callbacks. They call_fire_on_sent()/_fire_on_send_failed()on TCPConnection. These 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. With state objects, it gates on_state.is_open(). It remains on TCPConnection — not part of the state interface, since it's a simple one-line delegation toPonyTCP.keepalive.State transition ordering
hard_close()currently sets_connected = falseand_closed = trueat the top (lines 227-228), 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, SSL fields, flow control flags) 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,_inflight_connections(only relevant while connecting). But_Openand_Closingshare almost all data (fd, event, buffers, SSL), making ownership between them awkward. Data ownership can be added incrementally later where it proves valuable.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.
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()for Windows). State objects call into these methods, and theifdefblocks remain in the I/O layer. The state machine pattern doesn't resolve the IOCP complexity, but it doesn't worsen it either — platform branching stays where it is.SSL
SSL remains as fields on TCPConnection for this iteration. The
_ssl,_ssl_ready,_ssl_failed,_tls_upgradeflags 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.What changes
_event_notifysplits from one ~90-line method into per-state handlers, each handling only its relevant events. Disposable event handling stays in TCPConnection as a pre-dispatch step.send(),close(),start_tls()gate on state type rather than boolean flag checks._state = _Open) rather than scattered flag mutations.hard_close()callback routing could be partially simplified — the state knows whether we're in the connecting phase vs open._read_againbecomes state-aware, preventing reads on closed connections.What stays the same
_readable,_writeable,_throttled,_muted) remain on TCPConnection._read,_send_pending_writes, etc.) remain on TCPConnection.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 some states like_Openmay needreffor future data ownership), all states useclass. The allocation cost is minimal — state transitions are infrequent relative to I/O operations.Open questions
hard_close()during_ClientConnecting: Currently,hard_close()checksif not _connected then return end, making it a no-op during connecting. The actual failure-during-connecting path goes through_connecting_callback(): when a connection attempt fails in_event_notify, it calls_connecting_callback(), which fires_on_connection_failure()when all attempts are exhausted and then callshard_close()(which no-ops). For graceful cancellation,close()sets_closed = trueand_try_shutdown()waits for inflight connections to resolve naturally. In the state-object model, should_ClientConnecting.hard_close()actively cancel inflight connection attempts (unsubscribe events, close fds), or should it remain a no-op that relies onclose()and natural event resolution? This is a behavioral decision, not just a refactoring question._Closingas a separate state? From the user's perspective,_Closingbehaves identically to_Closed— all public operations return errors. The difference is internal: pending write draining and the shutdown handshake. A separate state makes the shutdown sequence explicit and avoids reintroducing a boolean flag within_Open. A flag within_Openkeeps the state count lower but partially defeats the purpose of the refactoring.Client/server dimension: Currently encoded as the type of
_lifecycle_event_receiver(client vs server). The_ClientConnectingstate is inherently client-only. Within_Open, the client/server distinction only affects callback names (_on_connectedvs_on_started,_on_connection_failurevs_on_start_failure). Keeping client/server orthogonal to lifecycle state (state objects match on the receiver type for callbacks) avoids duplicating nearly identical_ClientOpen/_ServerOpenclasses.All reactions