File Coverage

blib/lib/PAGI/Server/ConnectionState.pm
Criterion Covered Total %
statement 60 61 98.3
branch 20 22 90.9
condition 4 8 50.0
subroutine 9 9 100.0
pod 5 5 100.0
total 98 105 93.3


line stmt bran cond sub pod time code
1             package PAGI::Server::ConnectionState;
2              
3 90     90   296673 use strict;
  90         129  
  90         3228  
4 90     90   328 use warnings;
  90         132  
  90         3993  
5 90     90   380 use Scalar::Util qw(weaken);
  90         128  
  90         69598  
6              
7             =head1 NAME
8              
9             PAGI::Server::ConnectionState - Connection state tracking for HTTP requests
10              
11             =head1 SYNOPSIS
12              
13             my $conn = $scope->{'pagi.connection'};
14              
15             # Synchronous, non-destructive check
16             if ($conn->is_connected) {
17             # Client still connected
18             }
19              
20             # Get disconnect reason (undef while connected)
21             my $reason = $conn->disconnect_reason;
22              
23             # Register cleanup callback
24             $conn->on_disconnect(sub {
25             my ($reason) = @_;
26             cleanup_resources();
27             });
28              
29             # Await disconnect (if Future provided)
30             if (my $future = $conn->disconnect_future) {
31             my $reason = await $future;
32             }
33              
34             =head1 DESCRIPTION
35              
36             PAGI::Server::ConnectionState provides a mechanism for applications to detect
37             client disconnection without consuming messages from the receive queue.
38              
39             This addresses a fundamental limitation in the PAGI (and ASGI) model where
40             checking for disconnect via C may inadvertently consume request
41             body data.
42              
43             The C method lazily creates a Future only when called,
44             avoiding allocation overhead for simple request/response handlers that don't
45             need async disconnect detection.
46              
47             See the "Connection State" section in L for the
48             full specification.
49              
50             =head1 METHODS
51              
52             =head2 new
53              
54             my $conn = PAGI::Server::ConnectionState->new(connection => $connection);
55              
56             Creates a new connection state object. The C argument provides
57             a reference to the parent Connection object for lazy Future creation.
58              
59             =cut
60              
61             sub new {
62 340     340 1 49171 my ($class, %args) = @_;
63              
64 340         652 my $connected = 1;
65 340         615 my $reason = undef;
66              
67             my $self = bless {
68             # Connection reference for lazy Future creation (will be weakened)
69             _connection => $args{connection},
70              
71             # State (scalar refs - for internal consistency)
72 340         2907 _connected => \$connected,
73             _reason => \$reason,
74              
75             # Lazy Future (only created if disconnect_future() called)
76             _future => undef,
77              
78             # Callbacks registered via on_disconnect()
79             _callbacks => [],
80             }, $class;
81              
82             # Weaken to avoid circular reference: Connection -> ConnectionState -> Connection
83 340 100       1870 weaken($self->{_connection}) if $self->{_connection};
84              
85 340         973 return $self;
86             }
87              
88             =head2 is_connected
89              
90             my $connected = $conn->is_connected; # Boolean
91              
92             Returns true if the connection is still open, false if disconnected.
93              
94             This is a synchronous, non-destructive check that does not consume
95             messages from the receive queue.
96              
97             =cut
98              
99             sub is_connected {
100 13     13 1 22 my $self = shift;
101 13 100       14 return ${$self->{_connected}} ? 1 : 0;
  13         90  
102             }
103              
104             =head2 disconnect_reason
105              
106             my $reason = $conn->disconnect_reason; # String or undef
107              
108             Returns the disconnect reason string, or C if still connected.
109              
110             Standard reason strings:
111              
112             =over 4
113              
114             =item * C - Client initiated clean close (TCP FIN)
115              
116             =item * C - Client stopped responding (read timeout)
117              
118             =item * C - Connection idle too long between requests
119              
120             =item * C - Response write timed out
121              
122             =item * C - Socket write failed (EPIPE, ECONNRESET)
123              
124             =item * C - Socket read failed
125              
126             =item * C - HTTP parse error, invalid request
127              
128             =item * C - Server shutting down gracefully
129              
130             =item * C - Request body exceeded limit
131              
132             =back
133              
134             =cut
135              
136             sub disconnect_reason {
137 25     25 1 324 my $self = shift;
138 25         26 return ${$self->{_reason}};
  25         104  
139             }
140              
141             =head2 disconnect_future
142              
143             my $future = $conn->disconnect_future; # Future or undef
144             my $reason = await $future;
145              
146             Returns a Future that resolves when the connection closes.
147              
148             The Future is created lazily on first call, avoiding allocation overhead
149             for handlers that don't need async disconnect detection.
150              
151             The Future resolves with the disconnect reason string.
152              
153             This is useful for racing against other async operations:
154              
155             await Future->wait_any($disconnect_future, $event_future);
156              
157             =cut
158              
159             sub disconnect_future {
160 9     9 1 15 my $self = shift;
161              
162             # Return cached Future if exists
163 9 100       24 return $self->{_future} if $self->{_future};
164              
165             # Create new Future (lazy)
166 7         7 my $conn = $self->{_connection};
167 7 50 33     36 my $loop = $conn && $conn->{server} ? $conn->{server}->loop : undef;
168              
169 7 50       12 if ($loop) {
170 0         0 $self->{_future} = $loop->new_future;
171             } else {
172             # Fallback if no loop available (shouldn't happen in practice)
173 7         36 require Future;
174 7         41 $self->{_future} = Future->new;
175             }
176              
177             # If already disconnected, resolve immediately
178 7 100       45 unless (${$self->{_connected}}) {
  7         13  
179 2         4 $self->{_future}->done(${$self->{_reason}});
  2         5  
180             }
181              
182 7         60 return $self->{_future};
183             }
184              
185             =head2 on_disconnect
186              
187             $conn->on_disconnect(sub {
188             my ($reason) = @_;
189             # cleanup code
190             });
191              
192             Registers a callback to be invoked when disconnect occurs.
193              
194             =over 4
195              
196             =item * May be called multiple times to register multiple callbacks
197              
198             =item * Callbacks are invoked in registration order
199              
200             =item * Callbacks receive the disconnect reason as the first argument
201              
202             =item * If registered after disconnect already occurred, callback is
203             invoked immediately with the reason
204              
205             =item * One callback's failure does not prevent other callbacks from
206             being invoked
207              
208             =back
209              
210             =cut
211              
212             sub on_disconnect {
213 10     10 1 40 my ($self, $cb) = @_;
214              
215             # If already disconnected, invoke immediately
216 10 100       10 unless (${$self->{_connected}}) {
  10         52  
217 3         4 eval { $cb->(${$self->{_reason}}) };
  3         5  
  3         8  
218 3 100       26 warn "on_disconnect callback error: $@" if $@;
219 3         11 return;
220             }
221              
222 7         9 push @{$self->{_callbacks}}, $cb;
  7         15  
223             }
224              
225             =head2 _mark_disconnected
226              
227             $conn->_mark_disconnected($reason);
228              
229             B - Called by the server when disconnect is detected.
230              
231             Updates the connection state and invokes all registered callbacks.
232             Applications should not call this method directly.
233              
234             State transitions occur in this order:
235              
236             =over 4
237              
238             =item 1. C returns false
239              
240             =item 2. C returns the reason string
241              
242             =item 3. C resolves with the reason (if it was created)
243              
244             =item 4. C callbacks are invoked in registration order
245              
246             =back
247              
248             =cut
249              
250             sub _mark_disconnected {
251 63     63   1389 my ($self, $reason) = @_;
252              
253             # Already disconnected - no-op (idempotent)
254 63 100       113 return unless ${$self->{_connected}};
  63         176  
255              
256             # 1. Update state
257 62         85 ${$self->{_connected}} = 0;
  62         100  
258 62   50     126 ${$self->{_reason}} = $reason // 'unknown';
  62         139  
259              
260             # 2. Resolve future if it exists (lazy - may not have been created)
261 62 100 66     252 if ($self->{_future} && !$self->{_future}->is_ready) {
262 2         12 $self->{_future}->done(${$self->{_reason}});
  2         14  
263             }
264              
265             # 3. Invoke callbacks
266 62         169 for my $cb (@{$self->{_callbacks}}) {
  62         143  
267 7         33 eval { $cb->(${$self->{_reason}}) };
  7         7  
  7         14  
268 7 100       41 warn "on_disconnect callback error: $@" if $@;
269             }
270              
271             # 4. Clear callbacks to release references
272 62         176 $self->{_callbacks} = [];
273             }
274              
275             1;
276              
277             __END__