Skip to content

Commit 5fd7d64

Browse files
authored
Add connection timeout (#179)
Thread lori's `ConnectionTimeout` through `ServerConnectInfo` to `Session` so users can bound the TCP connection phase. Without this, connections to unreachable hosts hang until the OS-level TCP timeout. `ServerConnectInfo` gains an optional `connection_timeout` parameter (default `None`). When set, lori fires `ConnectionFailedTimeout` if TCP doesn't connect in time — the existing failure mapping (from PR #177) delivers it to `pg_session_connection_failed`. `_CancelSender` does not use the connection timeout — cancel connections are fire-and-forget with no user callback, so a timeout would have no observable effect. Design: #72
1 parent f8408c7 commit 5fd7d64

File tree

8 files changed

+175
-4
lines changed

8 files changed

+175
-4
lines changed
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
## Add connection timeout
2+
3+
You can now set a timeout on the TCP connection phase by passing a `connection_timeout` to `ServerConnectInfo`. If the server is unreachable within the given duration, `pg_session_connection_failed` fires with `ConnectionFailedTimeout` instead of hanging indefinitely. Construct the timeout with `lori.MakeConnectionTimeout(milliseconds)`.
4+
5+
```pony
6+
match lori.MakeConnectionTimeout(5000)
7+
| let ct: lori.ConnectionTimeout =>
8+
let session = Session(
9+
ServerConnectInfo(auth, host, port
10+
where connection_timeout' = ct),
11+
DatabaseConnectInfo(username, password, database),
12+
notify)
13+
end
14+
```
15+
16+
Without a connection timeout (the default), connection attempts have no time bound and rely on the operating system's TCP timeout behavior.

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ Only one operation is in-flight at a time. The queue serializes execution. `quer
136136
- `CodecRegistry` class (val) — maps OIDs to text and binary `Codec` instances. Immutable — `with_codec(oid, codec)?` returns a new registry with the codec registered (partial — errors if OID is already registered, built-in or custom, or collides with a built-in or custom array OID). `with_array_type(array_oid, element_oid)?` registers a custom array type mapping (partial — errors if element_oid is an array OID, array_oid collides with a registered OID, array_oid is already a custom array OID, array_oid is already a custom element OID, or array_oid == element_oid). `array_oid_for(element_oid)` returns the array OID (built-in + custom, 0 if unknown). Supports chaining: `CodecRegistry.with_codec(600, A)?.with_array_type(1017, 600)?`. Default constructor populates all built-in codecs. `decode(oid, format, data)` is partial — returns `FieldData` for known OIDs, fallbacks for unknown OIDs (unknown text→`String`, unknown binary→`RawBytes`), and errors when a registered codec's `decode()` fails. Intercepts array OIDs before normal codec dispatch. `has_binary_codec(oid)` for format selection (includes array OIDs)
137137
- `ClientQueryError` — union type `(SessionNeverOpened | SessionClosed | SessionNotAuthenticated | DataError)`
138138
- `DatabaseConnectInfo` — val class grouping database authentication parameters (user, password, database). Passed to `Session.create()` alongside `ServerConnectInfo`.
139-
- `ServerConnectInfo` — val class grouping connection parameters (auth, host, service, ssl_mode). Passed to `Session.create()` as the first parameter. Also used by `_CancelSender`.
139+
- `ServerConnectInfo` — val class grouping connection parameters (auth, host, service, ssl_mode, connection_timeout). Passed to `Session.create()` as the first parameter. Also used by `_CancelSender`. Optional `connection_timeout: (lori.ConnectionTimeout | None)` bounds the TCP connection phase; if the timeout fires before a connection is established, `pg_session_connection_failed` is called with `ConnectionFailedTimeout`.
140140
- `SSLMode` — union type `(SSLDisabled | SSLPreferred | SSLRequired)`. `SSLDisabled` is the default (plaintext). `SSLPreferred` wraps an `SSLContext val` and attempts SSL with plaintext fallback on server refusal (`sslmode=prefer`). `SSLRequired` wraps an `SSLContext val` and aborts on server refusal.
141141
- `ErrorResponseMessage` — full PostgreSQL error with all standard fields
142142
- `AuthenticationFailureReason` = `(InvalidAuthenticationSpecification | InvalidPassword | ServerVerificationFailed | UnsupportedAuthenticationMethod)`
@@ -189,7 +189,7 @@ Codec-based decoding via `CodecRegistry.decode(oid, format_code, data)`. Extende
189189

190190
Tests live in the main `postgres/` package (private test classes), organized across multiple files by concern (`_test_*.pony`). The `Main` test actor in `_test.pony` is the single test registry that lists all tests. Read the individual test files for per-test details.
191191

192-
**Conventions**: `_test.pony` contains shared helpers (`_ConnectionTestConfiguration` for env vars, `_ConnectTestNotify`/`_AuthenticateTestNotify` reused by other files). `_test_response_parser.pony` contains `_Incoming*TestMessage` builder classes that construct raw protocol bytes for mock servers across all test files. `_test_mock_message_reader.pony` contains `_MockMessageReader` for extracting complete PostgreSQL frontend messages from TCP data in mock servers. `_test_codecs.pony` contains unit tests for binary/text codecs, `CodecRegistry`, `_ParamEncoder`, and binary-format bind wire format. `_test_array.pony` contains unit tests for `_ArrayOidMap`, binary/text array decode, `PgArray` equality/string, `_ArrayEncoder` roundtrips, `_ParamEncoder` with `PgArray`, `_FrontendMessage.bind()` with `PgArray`, `_FieldDataEq` extraction, `_NumericBinaryCodec` encode roundtrip, `CodecRegistry` array methods, and integration tests for array SELECT/roundtrip. `_test_statement_timeout.pony` contains unit tests for statement timeout timer lifecycle (fires on slow query, cancelled on normal completion) and integration test for pg_sleep with timeout.
192+
**Conventions**: `_test.pony` contains shared helpers (`_ConnectionTestConfiguration` for env vars, `_ConnectTestNotify`/`_AuthenticateTestNotify` reused by other files). `_test_response_parser.pony` contains `_Incoming*TestMessage` builder classes that construct raw protocol bytes for mock servers across all test files. `_test_mock_message_reader.pony` contains `_MockMessageReader` for extracting complete PostgreSQL frontend messages from TCP data in mock servers. `_test_codecs.pony` contains unit tests for binary/text codecs, `CodecRegistry`, `_ParamEncoder`, and binary-format bind wire format. `_test_array.pony` contains unit tests for `_ArrayOidMap`, binary/text array decode, `PgArray` equality/string, `_ArrayEncoder` roundtrips, `_ParamEncoder` with `PgArray`, `_FrontendMessage.bind()` with `PgArray`, `_FieldDataEq` extraction, `_NumericBinaryCodec` encode roundtrip, `CodecRegistry` array methods, and integration tests for array SELECT/roundtrip. `_test_statement_timeout.pony` contains unit tests for statement timeout timer lifecycle (fires on slow query, cancelled on normal completion) and integration test for pg_sleep with timeout. `_test_connection_timeout.pony` contains unit test for connection timeout (connects to non-routable address, verifies `ConnectionFailedTimeout` is reported).
193193

194194
**Ports**: Mock server tests use ports in the 7669–7721 range and 9667–9668. **Port 7680 is reserved by Windows** (Update Delivery Optimization) and will fail to bind on WSL2 — do not use it.
195195

examples/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ SSL-encrypted query using `SSLRequired`. Same workflow as `query` but with TLS n
3838

3939
Query cancellation using `Session.cancel()`. Executes a long-running query (`SELECT pg_sleep(10)`), cancels it, and handles the resulting `ErrorResponseMessage` with SQLSTATE `57014` (query_canceled) in the `ResultReceiver`. Shows that cancellation is best-effort and arrives as a query failure, not a separate callback.
4040

41+
## connection-timeout
42+
43+
Connection timeout using the `connection_timeout` parameter on `ServerConnectInfo`. Connects to a configurable host and port with a 3-second timeout via `lori.MakeConnectionTimeout(3000)`, and handles `ConnectionFailedTimeout` in `pg_session_connection_failed`. Shows how the driver reports unreachable servers without hanging indefinitely.
44+
4145
## crud
4246

4347
Multi-query workflow mixing `SimpleQuery` and `PreparedQuery`. Creates a table, inserts rows with parameterized INSERTs, selects them back, deletes, and drops the table. Demonstrates all three `Result` types (`ResultSet`, `RowModifying`, `SimpleResult`) and `ErrorResponseMessage` error handling.
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""
2+
Connection timeout using the `connection_timeout` parameter on
3+
`ServerConnectInfo`. Connects to a configurable host and port with a 3-second
4+
timeout. If the server is unreachable within the timeout, the session reports
5+
`ConnectionFailedTimeout` via `pg_session_connection_failed`.
6+
"""
7+
use "cli"
8+
use "collections"
9+
use "constrained_types"
10+
use lori = "lori"
11+
// in your code this `use` statement would be:
12+
// use "postgres"
13+
use "../../postgres"
14+
15+
actor Main
16+
new create(env: Env) =>
17+
let server_info = ServerInfo(env.vars)
18+
let auth = lori.TCPConnectAuth(env.root)
19+
20+
let client = Client(auth, server_info, env.out)
21+
22+
actor Client is SessionStatusNotify
23+
let _session: Session
24+
let _out: OutStream
25+
26+
new create(auth: lori.TCPConnectAuth, info: ServerInfo, out: OutStream) =>
27+
_out = out
28+
match lori.MakeConnectionTimeout(3000)
29+
| let ct: lori.ConnectionTimeout =>
30+
_out.print("Connecting with 3-second timeout...")
31+
_session = Session(
32+
ServerConnectInfo(auth, info.host, info.port
33+
where connection_timeout' = ct),
34+
DatabaseConnectInfo(info.username, info.password, info.database),
35+
this)
36+
| let _: ValidationFailure =>
37+
_out.print("Failed to create connection timeout.")
38+
_session = Session(
39+
ServerConnectInfo(auth, info.host, info.port),
40+
DatabaseConnectInfo(info.username, info.password, info.database),
41+
this)
42+
end
43+
44+
be pg_session_connected(session: Session) =>
45+
_out.print("Connected.")
46+
47+
be pg_session_connection_failed(s: Session,
48+
reason: ConnectionFailureReason)
49+
=>
50+
match reason
51+
| ConnectionFailedTimeout =>
52+
_out.print("Connection timed out.")
53+
| ConnectionFailedDNS =>
54+
_out.print("DNS resolution failed.")
55+
| ConnectionFailedTCP =>
56+
_out.print("TCP connection failed.")
57+
| SSLServerRefused =>
58+
_out.print("SSL refused by server.")
59+
| TLSAuthFailed =>
60+
_out.print("TLS authentication failed.")
61+
| TLSHandshakeFailed =>
62+
_out.print("TLS handshake failed.")
63+
end
64+
65+
be pg_session_authenticated(session: Session) =>
66+
_out.print("Authenticated.")
67+
session.close()
68+
69+
be pg_session_authentication_failed(
70+
s: Session,
71+
reason: AuthenticationFailureReason)
72+
=>
73+
_out.print("Failed to authenticate.")
74+
75+
class val ServerInfo
76+
let host: String
77+
let port: String
78+
let username: String
79+
let password: String
80+
let database: String
81+
82+
new val create(vars: (Array[String] val | None)) =>
83+
let e = EnvVars(vars)
84+
host = try e("POSTGRES_HOST")? else "127.0.0.1" end
85+
port = try e("POSTGRES_PORT")? else "5432" end
86+
username = try e("POSTGRES_USERNAME")? else "postgres" end
87+
password = try e("POSTGRES_PASSWORD")? else "postgres" end
88+
database = try e("POSTGRES_DATABASE")? else "postgres" end

postgres/_test.pony

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ actor \nodoc\ Main is TestList
123123
test(_TestSSLCancelQueryInFlight)
124124
test(_TestCancelPgSleep)
125125
test(_TestCancelSSLPgSleep)
126+
test(_TestConnectionTimeoutFires)
126127
test(_TestStatementTimeoutFires)
127128
test(_TestStatementTimeoutCancelledOnCompletion)
128129
test(_TestStatementTimeoutPgSleep)
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
use "constrained_types"
2+
use lori = "lori"
3+
use "pony_test"
4+
5+
class \nodoc\ iso _TestConnectionTimeoutFires is UnitTest
6+
"""
7+
Verifies that when a connection timeout is set and the server is unreachable,
8+
pg_session_connection_failed fires with ConnectionFailedTimeout.
9+
Connects to 192.0.2.1 (RFC 5737 TEST-NET-1, guaranteed non-routable).
10+
"""
11+
fun name(): String =>
12+
"ConnectionTimeout/Fires"
13+
14+
fun apply(h: TestHelper) =>
15+
match lori.MakeConnectionTimeout(100)
16+
| let ct: lori.ConnectionTimeout =>
17+
let session = Session(
18+
ServerConnectInfo(lori.TCPConnectAuth(h.env.root),
19+
"192.0.2.1", "9999"
20+
where connection_timeout' = ct),
21+
DatabaseConnectInfo("postgres", "postgres", "postgres"),
22+
_ConnectionTimeoutTestClient(h))
23+
h.dispose_when_done(session)
24+
| let _: ValidationFailure =>
25+
h.fail("Failed to create ConnectionTimeout.")
26+
h.complete(false)
27+
end
28+
h.long_test(5_000_000_000)
29+
30+
actor \nodoc\ _ConnectionTimeoutTestClient is SessionStatusNotify
31+
let _h: TestHelper
32+
33+
new create(h: TestHelper) =>
34+
_h = h
35+
36+
be pg_session_connection_failed(s: Session,
37+
reason: ConnectionFailureReason)
38+
=>
39+
match reason
40+
| ConnectionFailedTimeout =>
41+
_h.complete(true)
42+
else
43+
_h.fail("Expected ConnectionFailedTimeout but got different reason.")
44+
_h.complete(false)
45+
end
46+
47+
be pg_session_authenticated(session: Session) =>
48+
_h.fail("Should not have connected.")
49+
_h.complete(false)

postgres/server_connect_info.pony

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,24 @@ class val ServerConnectInfo
44
"""
55
Connection parameters needed to reach the PostgreSQL server. Grouped because
66
they are always used together — individually they have no meaning.
7+
8+
An optional `connection_timeout` bounds the TCP connection phase. If the
9+
timeout fires before a TCP connection is established, `pg_session_connection_failed`
10+
is called with `ConnectionFailedTimeout`. Construct the timeout with
11+
`lori.MakeConnectionTimeout(milliseconds)`.
712
"""
813
let auth: lori.TCPConnectAuth
914
let host: String
1015
let service: String
1116
let ssl_mode: SSLMode
17+
let connection_timeout: (lori.ConnectionTimeout | None)
1218

1319
new val create(auth': lori.TCPConnectAuth, host': String, service': String,
14-
ssl_mode': SSLMode = SSLDisabled)
20+
ssl_mode': SSLMode = SSLDisabled,
21+
connection_timeout': (lori.ConnectionTimeout | None) = None)
1522
=>
1623
auth = auth'
1724
host = host'
1825
service = service'
1926
ssl_mode = ssl_mode'
27+
connection_timeout = connection_timeout'

postgres/session.pony

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ actor Session is (lori.TCPConnectionActor & lori.ClientLifecycleEventReceiver)
2020
multiple queries are sent to the server in a single write and processed
2121
sequentially, reducing round-trip latency.
2222
23+
An optional connection timeout can be set via `ServerConnectInfo`. If the
24+
TCP connection is not established within the given duration,
25+
`pg_session_connection_failed` is called with `ConnectionFailedTimeout`.
26+
2327
Most operations accept an optional `statement_timeout` parameter. When
2428
provided, the driver automatically sends a CancelRequest if the operation
2529
does not complete within the given duration. Construct the timeout with
@@ -45,7 +49,8 @@ actor Session is (lori.TCPConnectionActor & lori.ClientLifecycleEventReceiver)
4549
server_connect_info'.service,
4650
"",
4751
this,
48-
this)
52+
this
53+
where connection_timeout = server_connect_info'.connection_timeout)
4954

5055
be execute(query: Query, receiver: ResultReceiver,
5156
statement_timeout: (lori.TimerDuration | None) = None)

0 commit comments

Comments
 (0)