File Coverage

blib/lib/PAGI/Server/ConnectionState.pm
Criterion Covered Total %
statement 88 89 98.8
branch 32 36 88.8
condition 4 8 50.0
subroutine 13 13 100.0
pod 7 7 100.0
total 144 153 94.1


line stmt bran cond sub pod time code
1             package PAGI::Server::ConnectionState;
2              
3 111     111   393327 use strict;
  111         217  
  111         4001  
4 111     111   422 use warnings;
  111         162  
  111         6660  
5              
6             our $VERSION = '0.002001';
7              
8 111     111   495 use Scalar::Util qw(weaken);
  111         170  
  111         126938  
9              
10             =encoding utf8
11              
12             =head1 NAME
13              
14             PAGI::Server::ConnectionState - Connection state tracking for HTTP requests
15              
16             =head1 SYNOPSIS
17              
18             my $conn = $scope->{'pagi.connection'};
19              
20             # Synchronous, non-destructive check
21             if ($conn->is_connected) {
22             # Client still connected
23             }
24              
25             # Get disconnect reason (undef while connected)
26             my $reason = $conn->disconnect_reason;
27              
28             # Register a callback for an abnormal end (client gone, timeout, error)
29             $conn->on_disconnect(sub {
30             my ($reason) = @_;
31             rollback();
32             });
33              
34             # ...and its counterpart for a clean finish. Exactly one of the two fires.
35             $conn->on_complete(sub {
36             commit();
37             });
38              
39             # Await abnormal disconnect (if Future provided)
40             if (my $future = $conn->disconnect_future) {
41             my $reason = await $future;
42             }
43              
44             =head1 DESCRIPTION
45              
46             PAGI::Server::ConnectionState provides a mechanism for applications to detect
47             client disconnection without consuming messages from the receive queue.
48              
49             This addresses a fundamental limitation in the PAGI (and ASGI) model where
50             checking for disconnect via C may inadvertently consume request
51             body data.
52              
53             The C method lazily creates a Future only when called,
54             avoiding allocation overhead for simple request/response handlers that don't
55             need async disconnect detection.
56              
57             See the "Connection State" section in L for the full
58             specification.
59              
60             =head1 METHODS
61              
62             =head2 new
63              
64             my $conn = PAGI::Server::ConnectionState->new(connection => $connection);
65              
66             Creates a new connection state object. The C argument provides
67             a reference to the parent Connection object for lazy Future creation.
68              
69             =cut
70              
71             sub new {
72 239     239 1 175354 my ($class, %args) = @_;
73              
74 239         460 my $connected = 1;
75 239         438 my $reason = undef;
76              
77             my $self = bless {
78             # Connection reference for lazy Future creation (will be weakened)
79             _connection => $args{connection},
80              
81             # State (scalar refs - for internal consistency)
82 239         3432 _connected => \$connected,
83             _reason => \$reason,
84              
85             # Distinguishes the two terminal states once _connected is false:
86             # an abnormal disconnect (false) vs. a clean completion (true).
87             _completed => 0,
88              
89             # Response progress (HTTP): set when http.response.start is emitted.
90             _response_started => 0,
91              
92             # Lazy Future (only created if disconnect_future() called)
93             _future => undef,
94              
95             # Callbacks registered via on_disconnect()
96             _callbacks => [],
97              
98             # Callbacks registered via on_complete()
99             _complete_callbacks => [],
100             }, $class;
101              
102             # Weaken to avoid circular reference: Connection -> ConnectionState -> Connection
103 239 100       1408 weaken($self->{_connection}) if $self->{_connection};
104              
105 239         763 return $self;
106             }
107              
108             =head2 is_connected
109              
110             my $connected = $conn->is_connected; # Boolean
111              
112             Returns true if the connection is still open, false if disconnected.
113              
114             This is a synchronous, non-destructive check that does not consume
115             messages from the receive queue.
116              
117             =cut
118              
119             sub is_connected {
120 4     4 1 469 my $self = shift;
121 4 100       5 return ${$self->{_connected}} ? 1 : 0;
  4         23  
122             }
123              
124             =head2 response_started
125              
126             my $started = $conn->response_started; # 0 or 1
127              
128             True once the server has started this request's response (C
129             emitted -- by the application, a framework, a middleware, or a server-synthesized
130             error/backstop response). Server-owned; read-only to applications.
131              
132             =cut
133              
134 7 100   7 1 919 sub response_started { return $_[0]->{_response_started} ? 1 : 0 }
135              
136             # Server-internal: called from the send path when http.response.start is emitted.
137 211     211   532 sub _mark_response_started { $_[0]->{_response_started} = 1; return }
  211         364  
138              
139             =head2 disconnect_reason
140              
141             my $reason = $conn->disconnect_reason; # String or undef
142              
143             Returns the disconnect reason string, or C if still connected B
144             request completed normally> -- every reason below describes an abnormal end.
145              
146             Standard reason strings:
147              
148             =over 4
149              
150             =item * C - Client initiated clean close (TCP FIN) mid-request
151              
152             =item * C - Client stopped responding (read timeout)
153              
154             =item * C - Connection idle too long before the request arrived
155              
156             =item * C - Keep-alive connection idled out between requests
157              
158             =item * C - Response write timed out
159              
160             =item * C - Socket write failed (EPIPE, ECONNRESET)
161              
162             =item * C - Socket read failed
163              
164             =item * C - HTTP parse error, invalid request
165              
166             =item * C - Server shutting down gracefully
167              
168             =item * C - Unhandled server-side error aborted the request
169              
170             =item * C - Request body exceeded limit
171              
172             =item * C - A bounded server queue overflowed; connection dropped
173              
174             =back
175              
176             See L for the authoritative list.
177              
178             =cut
179              
180             sub disconnect_reason {
181 16     16 1 679 my $self = shift;
182 16         16 return ${$self->{_reason}};
  16         52  
183             }
184              
185             =head2 disconnect_future
186              
187             my $future = $conn->disconnect_future; # Future or undef
188             my $reason = await $future;
189              
190             Returns a Future that resolves when the connection closes B
191             (client disconnect, transport error). On a clean completion the connection
192             closes but this Future is deliberately left pending — use C to
193             observe normal completion.
194              
195             The Future is created lazily on first call, avoiding allocation overhead
196             for handlers that don't need async disconnect detection.
197              
198             The Future resolves with the disconnect reason string.
199              
200             This is useful for racing against other async operations:
201              
202             await Future->wait_any($disconnect_future, $event_future);
203              
204             =cut
205              
206             sub disconnect_future {
207 7     7 1 17 my $self = shift;
208              
209             # Return cached Future if exists
210 7 100       14 return $self->{_future} if $self->{_future};
211              
212             # Create new Future (lazy)
213 6         8 my $conn = $self->{_connection};
214 6 50 33     13 my $loop = $conn && $conn->{server} ? $conn->{server}->loop : undef;
215              
216 6 50       9 if ($loop) {
217 0         0 $self->{_future} = $loop->new_future;
218             } else {
219             # Fallback if no loop available (shouldn't happen in practice)
220 6         36 require Future;
221 6         28 $self->{_future} = Future->new;
222             }
223              
224             # If already disconnected, resolve immediately
225 6 100       37 unless (${$self->{_connected}}) {
  6         11  
226 2         3 $self->{_future}->done(${$self->{_reason}});
  2         8  
227             }
228              
229 6         61 return $self->{_future};
230             }
231              
232             =head2 on_disconnect
233              
234             $conn->on_disconnect(sub {
235             my ($reason) = @_;
236             # cleanup code
237             });
238              
239             Registers a callback to be invoked when disconnect occurs.
240              
241             =over 4
242              
243             =item * May be called multiple times to register multiple callbacks
244              
245             =item * Callbacks are invoked in registration order
246              
247             =item * Callbacks receive the disconnect reason as the first argument
248              
249             =item * If registered after disconnect already occurred, callback is
250             invoked immediately with the reason
251              
252             =item * One callback's failure does not prevent other callbacks from
253             being invoked
254              
255             =back
256              
257             =cut
258              
259             sub on_disconnect {
260 10     10 1 54 my ($self, $cb) = @_;
261              
262             # Still in flight: register for later.
263 10 100       10 if (${$self->{_connected}}) {
  10         19  
264 7         8 push @{$self->{_callbacks}}, $cb;
  7         11  
265 7         12 return;
266             }
267              
268             # Terminal: only fire if the request ended abnormally, not on clean
269             # completion (on_disconnect means "something went wrong").
270 3 100       8 return if $self->{_completed};
271              
272 2         5 eval { $cb->(${$self->{_reason}}) };
  2         4  
  2         3  
273 2 100       24 warn "on_disconnect callback error: $@" if $@;
274             }
275              
276             =head2 on_complete
277              
278             $conn->on_complete(sub {
279             # request finished cleanly
280             });
281              
282             Registers a callback invoked B
283             (the response was fully delivered without the client disconnecting). It is the
284             counterpart to L: exactly one of the two fires for a given
285             request.
286              
287             =over 4
288              
289             =item * May be called multiple times to register multiple callbacks
290              
291             =item * Callbacks are invoked in registration order, with no arguments
292              
293             =item * If registered after the request already completed, the callback is
294             invoked immediately
295              
296             =item * If the request ended in an abnormal disconnect, the callback never fires
297              
298             =item * One callback's failure does not prevent other callbacks from being
299             invoked
300              
301             =back
302              
303             =cut
304              
305             sub on_complete {
306 5     5 1 27 my ($self, $cb) = @_;
307              
308             # Still in flight: register for later.
309 5 100       5 if (${$self->{_connected}}) {
  5         11  
310 4         4 push @{$self->{_complete_callbacks}}, $cb;
  4         8  
311 4         6 return;
312             }
313              
314             # Terminal: only fire on clean completion, not on abnormal disconnect.
315 1 50       4 return unless $self->{_completed};
316              
317 1         2 eval { $cb->() };
  1         3  
318 1 50       4 warn "on_complete callback error: $@" if $@;
319             }
320              
321             =head2 _mark_disconnected
322              
323             $conn->_mark_disconnected($reason);
324              
325             B - Called by the server when disconnect is detected.
326              
327             Updates the connection state and invokes all registered callbacks.
328             Applications should not call this method directly.
329              
330             State transitions occur in this order:
331              
332             =over 4
333              
334             =item 1. C returns false
335              
336             =item 2. C returns the reason string
337              
338             =item 3. C resolves with the reason (if it was created)
339              
340             =item 4. C callbacks are invoked in registration order
341              
342             =back
343              
344             =cut
345              
346             sub _mark_disconnected {
347 38     38   611 my ($self, $reason) = @_;
348              
349             # Already disconnected - no-op (idempotent)
350 38 100       45 return unless ${$self->{_connected}};
  38         92  
351              
352             # 1. Update state
353 36         54 ${$self->{_connected}} = 0;
  36         79  
354 36   50     67 ${$self->{_reason}} = $reason // 'unknown';
  36         58  
355              
356             # 2. Resolve future if it exists (lazy - may not have been created)
357 36 100 66     97 if ($self->{_future} && !$self->{_future}->is_ready) {
358 1         7 $self->{_future}->done(${$self->{_reason}});
  1         9  
359             }
360              
361             # 3. Invoke callbacks
362 36         93 for my $cb (@{$self->{_callbacks}}) {
  36         81  
363 5         10 eval { $cb->(${$self->{_reason}}) };
  5         5  
  5         10  
364 5 100       35 warn "on_disconnect callback error: $@" if $@;
365             }
366              
367             # 4. Clear callbacks to release references. The request ended abnormally,
368             # so on_complete callbacks never run.
369 36         93 $self->{_callbacks} = [];
370 36         93 $self->{_complete_callbacks} = [];
371             }
372              
373             =head2 _mark_complete
374              
375             $conn->_mark_complete;
376              
377             B - Called by the server when the request completes
378             successfully (the response was fully delivered). Applications should not call
379             this method directly.
380              
381             Transitions to the C terminal state and invokes C
382             callbacks in registration order. Unlike L, it leaves
383             C as C and does B resolve C
384             or fire C callbacks -- a clean completion is not a disconnect.
385              
386             Idempotent, and a no-op once the connection has already reached a terminal
387             state (so a stray completion after an abnormal disconnect is ignored).
388              
389             =cut
390              
391             sub _mark_complete {
392 162     162   698 my ($self) = @_;
393              
394             # Already terminal (disconnected or completed) - no-op (idempotent).
395 162 100       290 return unless ${$self->{_connected}};
  162         474  
396              
397             # Mark the completed terminal state. Reason stays undef; the disconnect
398             # Future is deliberately left pending (completion is not a disconnect).
399 161         274 ${$self->{_connected}} = 0;
  161         394  
400 161         304 $self->{_completed} = 1;
401              
402             # Invoke completion callbacks (no reason argument).
403 161         273 for my $cb (@{$self->{_complete_callbacks}}) {
  161         485  
404 3         9 eval { $cb->() };
  3         5  
405 3 100       50 warn "on_complete callback error: $@" if $@;
406             }
407              
408             # Clear both lists to release references.
409 161         406 $self->{_complete_callbacks} = [];
410 161         435 $self->{_callbacks} = [];
411             }
412              
413             1;
414              
415             __END__