Repository navigation
Per-Connection Idle Timeout via ASIO Timer #193
Replies: 2 comments
Review Decisions1. Reset once per read eventMove the timer reset from 2. Don't clear
|
| Test | Timeout | Send interval | Rounds | Margin | Approx duration |
|---|---|---|---|---|---|
| 1 (basic fires) | 5s | — | — | — | ~5s |
| 2 (reset on activity) | 5s | 1s | 3 | 4s | ~8s |
| 3 (disable) | 5s | — | — | watchdog at 10s | ~10s |
Long_test timeouts at 3x expected duration (15s, 25s, 30s) to absorb extreme jitter.
Test 2 send scheduling uses the stdlib Timers actor with one-shot Timer callbacks in the test client. This is fine for tests — the shared-actor muting problem that motivates this feature doesn't apply in test contexts.
5. Simplify _reset_idle_timer()
Remove the fallback to _arm_idle_timer(). The method becomes: if not _timer_event.is_null() then PonyAsio.set_timer(_timer_event, _idle_timeout_nsec). During normal I/O, _timer_event is null only when _idle_timeout_nsec is 0 (no timeout configured) or pre-connection (no I/O happening). The fallback path is unreachable.
6. Document keepalive vs idle_timeout distinction
The idle_timeout docstring should note that TCP keepalive is a transport-level probe for detecting dead connections, while idle_timeout is application-level inactivity detection. They are independent mechanisms and can be used together.
Uh oh!
There was an error while loading. Please reload this page.
Context
GitHub issue ponylang/stallion#46: the current idle timer approach in lori_http_server requires a shared
Timersactor, creating two problems: (1) no timeout if the user doesn't supply one (footgun), and (2) if any connection gets backpressured the sharedTimersactor can get muted, disabling idle timeouts for all connections (livelock). This PR adds the plumbing to lori so consumers can use per-connection idle timeouts without any extra actors, using the ASIO timer mechanism directly.Approach
Add a second ASIO event (timer) per connection alongside the existing socket event. The Pony runtime already supports timerfd (Linux), EVFILT_TIMER (macOS/BSD), and WaitableTimer (Windows) through the same
pony_asio_event_createAPI that the stdlibTimersactor uses. Timer events arrive at the same_event_notifybehavior as socket events and are distinguished viaevent is _timer_event.The timer resets on I/O activity (both send and receive), making it a true idle timeout. No extra actors, no inter-actor messaging, no muting risk.
Steps
1. Add FFI declaration and PonyAsio wrappers
File:
lori/pony_asio.ponyuse @pony_asio_event_setnsec[U32](event: AsioEventID, nsec: U64)FFI declarationcreate_timer_event(the_actor, nsec)wrapper (calls@pony_asio_event_createwithfd=0, AsioEvent.timer(), noisy=true— keeps the program alive while a timer is active)set_timer(event, nsec)wrapper (calls@pony_asio_event_setnsec)2. Add
_on_idle_timeout()to lifecycle receiver traitsFile:
lori/lifecycle_event_receiver.ponyAdd
_on_idle_timeout()with no-op default to bothServerLifecycleEventReceiverandClientLifecycleEventReceiver. Docstring explains: fires when no send/receive for the configured duration, application decides what to do, timer auto-rearms, callidle_timeout(0)to disable.3. Add timer fields and methods to TCPConnection
File:
lori/tcp_connection.ponyNew fields (after SSL fields):
_timer_event: AsioEventID = AsioEvent.none()_idle_timeout_nsec: U64 = 0Public API —
idle_timeout(nsec: U64)placed afterkeepalive():_idle_timeout_nsec = nsecnsec > 0: arms or resets the timernsec == 0: cancels the timerInternal methods:
_arm_idle_timer()— create ASIO timer event viaPonyAsio.create_timer_event(enclosing, nsec). Called when connection is established and_idle_timeout_nsec > 0._reset_idle_timer()— guardsnot _timer_event.is_null()before callingPonyAsio.set_timer(_timer_event, nsec), else creates it via_arm_idle_timer(). Called on I/O activity._cancel_idle_timer()— guardsnot _timer_event.is_null()beforePonyAsio.unsubscribe+ clear. Called onhard_close()andidle_timeout(0). The null guard is critical: unsubscribing a nullAsioEventIDtriggerspony_assert(0)in the runtime._fire_idle_timeout()— dispatches_on_idle_timeout()to lifecycle receiver, then re-arms ifis_open()and_idle_timeout_nsec > 0.4. Integrate timer into existing code paths
File:
lori/tcp_connection.ponyDispatch — at the top of
_event_notify, beforeif event is _event:Arm — when TCP connection is established (before SSL handshake, so stalled handshakes trigger timeout):
_complete_server_initialization: after_set_writeable()(line ~1189)_event_notifyHappy Eyeballs success: after_set_readable()(line ~1055)Reset on activity:
_deliver_received(): first line, beforematch _ssl(any received data = activity)send(): before returning the token (line ~501)Cancel on close:
hard_close(): beforePonyAsio.unsubscribe(_event)in the connected branch (line ~275), and in the not-connected early return branch (line ~251)5. Add tests
File:
lori/_test.ponyThree new tests, ports 7897-7899:
_TestIdleTimeout(port 7897) — Server sets 500ms idle timeout, client connects but sends nothing. Verify_on_idle_timeoutfires. Test passes when the callback fires._TestIdleTimeoutReset(port 7898) — Server sets 500ms idle timeout, client sends data at 200ms intervals for 3 rounds (600ms total). Idle timeout should NOT fire during active sending. After client stops, timeout fires after 500ms of silence. Verify both that all data was received AND that the timeout fires after inactivity._TestIdleTimeoutDisable(port 7899) — Server sets 500ms idle timeout then immediately callsidle_timeout(0). Wait 1 second._on_idle_timeoutshould never fire. Use a Timers-based watchdog to complete the test after 1s with success. Requires addinguse "time"import to_test.pony.Build and run:
make test ssl=3.0.x6. Add idle-timeout example
New file:
examples/idle-timeout/idle-timeout.ponyEcho server variant with 10-second idle timeout. On
_on_started, callsidle_timeout(10_000_000_000). On_on_idle_timeout, callsclose(). Port 7672.7. Update examples README
File:
examples/README.mdAdd idle-timeout entry after "backpressure" (ordered simplest to most involved — idle-timeout is a simple server variant).
8. Update package docstring
File:
lori/lori.ponyAdd "Idle Timeout" section after "Connection Limits" with usage example showing
idle_timeout()and_on_idle_timeout().9. Create release notes
File:
.release-notes/next-release.mdAppend section: "Add per-connection idle timeout". Classify as added. PR label:
changelog - added.10. Update CLAUDE.md
File:
CLAUDE.mdidle-timeout/to examples list_on_idle_timeoutto shared callbacksEdge Cases
_deliver_received, resetting the timer. Only a stalled handshake (no data) triggers timeout — correct behavior._cancel_idle_timer()unsubscribes. Final disposable event is handled. Timer cannot fire after close._idle_timeout_nsecand resets/creates the timer. No duplicate events._arm_idle_timer()called when connection establishes._idle_timeout_nsec = 0by default, all timer methods are no-ops.Verification
Manual: run idle-timeout example, connect via
netcat localhost 7672, verify echoing works and connection closes after 10s of silence.All reactions