Skip to content

Commit 6860d62

Browse files
committed
Keep a quiet connection alive, and rebuild one whose path has gone
The reference client sends a keep-alive every fifteen seconds regardless of what was negotiated. On a server that costs nothing; on a phone it is a radio wake every fifteen seconds for the life of the tunnel, and the radio is the expensive part of a mobile connection by a wide margin — a modem woken that often never reaches its low-power state at all. So the interval is read off the timeout actually in force: the smaller of the two ends' max_idle_timeout, halved, floored and capped. The floor exists because a peer announcing a very short timeout would otherwise produce a keep-alive storm, and below it a connection is better left to close and be rebuilt — one handshake against a permanently busy radio. The cap exists because middleboxes forget UDP mappings long before QUIC forgets connections, so an interval derived only from the peer's generosity would be sent into a path that no longer exists. Against upstream's own two-minute default that is thirty seconds rather than fifteen, and the arithmetic is checked over every timeout from one second to ten minutes rather than at the two or three a hand-written case would pick. The device case proves the PING is actually sent, which the arithmetic cannot: between a correct interval and a surviving tunnel there are three assumptions — that ngtcp2 was told, that it sends, and that the Portal counts it as activity. Its first version was wrong in a way worth keeping. It opened a connection, slept, and asked for a flow, and the Portal had already closed it with "authentication deadline elapsed". **A QUIC connection carries its AuthFrame on its first flow**, so a connection that has opened none has not authenticated, and upstream's five-second handshake deadline ends it long before any idle timeout is in question. Keep-alive is not what keeps an unauthenticated connection alive; nothing is. Rebuilding is the other half. A QUIC connection whose path has gone is not something ngtcp2 recovers — the path it was built on no longer exists, it gives up, and every later call answers the same way. Until now the service opened one connection and that was that: after a network change the tunnel stayed up and carried nothing, which is worse than failing, because a user watching a "Connected" screen has no reason to restart it. And a rebuilt connection is a new one that has never authenticated. The carrier used to remember having authenticated at all, which would have sent the first flow on the new connection with no AuthFrame — answered with the silence every unauthenticated flow gets. It now remembers which connection it authenticated on, and the stream factory says which that is. L3: twenty-five covered, two partly, nothing blocked. The two partial rows are honest about the half that needs hardware this project has never had — row 10's stream-credit refusal, which the reference Portal never produces, and row 26's network change, which an emulator cannot stage. Gates green; device suite 51 cases; differential 27 cases across five carriers.
1 parent 32bce63 commit 6860d62

10 files changed

Lines changed: 567 additions & 33 deletions

File tree

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
// SPDX-License-Identifier: GPL-3.0-only
2+
// Copyright (C) 2026 The Somewhere Authors
3+
4+
package eu.nodepass.somewhere.quic
5+
6+
import androidx.test.ext.junit.runners.AndroidJUnit4
7+
import eu.nodepass.somewhere.protocol.DecodeResult
8+
import eu.nodepass.somewhere.protocol.frame.FlowKind
9+
import eu.nodepass.somewhere.protocol.session.NowhereSession
10+
import eu.nodepass.somewhere.protocol.session.QuicCarrier
11+
import eu.nodepass.somewhere.protocol.target.Target
12+
import eu.nodepass.somewhere.vpn.E2eEnvironment
13+
import org.junit.Assert.assertNotNull
14+
import org.junit.Assert.assertTrue
15+
import org.junit.Test
16+
import org.junit.runner.RunWith
17+
import java.net.InetSocketAddress
18+
import kotlin.time.Duration.Companion.nanoseconds
19+
20+
/**
21+
* A quiet connection stays open, and the interval it stays open with is derived.
22+
*
23+
* ## Why the arithmetic is not enough
24+
*
25+
* `KeepAliveTest` proves the number is inside the timeout. It cannot prove the
26+
* PING is actually sent, that ngtcp2 was told, or that the Portal counts it as
27+
* activity — which is three assumptions between a correct interval and a tunnel
28+
* that survives being idle.
29+
*
30+
* Run against a Portal with `NOW_UDP_IDLE_TIMEOUT=20s`. The default is two
31+
* minutes, and a case that waited that out would spend most of a suite's time
32+
* asleep to prove one thing.
33+
*/
34+
@RunWith(AndroidJUnit4::class)
35+
class QuicKeepAliveTest {
36+
@Test
37+
fun theIntervalIsDerivedFromWhatTheTwoEndsNegotiated() {
38+
withConnection { connection ->
39+
val idle = connection.idleTimeoutNanos().nanoseconds
40+
assertTrue("no idle timeout was negotiated at all", idle.inWholeSeconds > 0)
41+
42+
val interval = connection.keepAliveInterval
43+
assertNotNull("no keep-alive was set after the handshake", interval)
44+
assertTrue(
45+
"a $interval keep-alive does not stay inside a $idle timeout",
46+
KeepAlive.staysInside(interval!!, idle),
47+
)
48+
}
49+
}
50+
51+
/**
52+
* The claim itself: an idle **authenticated** connection outlives the
53+
* timeout and still opens a flow.
54+
*
55+
* The word that had to be added is "authenticated", and the first version
56+
* of this case did not have it. It opened a connection, slept, and then
57+
* asked for a flow — and the Portal had already closed it, with
58+
* `authentication deadline elapsed` in its log. **A QUIC connection carries
59+
* its AuthFrame on its first flow**, so a connection that has not opened
60+
* one has not authenticated, and upstream's five-second handshake deadline
61+
* ends it long before any idle timeout is in question. Keep-alive is not
62+
* what keeps an unauthenticated connection alive; nothing is.
63+
*
64+
* The flow at the end is what makes this a measurement rather than a wait.
65+
* A connection that had quietly died would look identical from here — QUIC
66+
* does not announce an idle close — and only asking it to do something
67+
* distinguishes the two.
68+
*/
69+
@Test
70+
fun anIdleAuthenticatedConnectionOutlivesTheIdleTimeoutAndStillOpensAFlow() {
71+
withConnection { connection ->
72+
val idle = connection.idleTimeoutNanos().nanoseconds
73+
val session =
74+
NowhereSession(
75+
sharedKey = key(),
76+
connect = { error("a QUIC session must not dial TLS") },
77+
quicStreams =
78+
QuicCarrier.StreamFactory {
79+
QuicStreamTransport(connection, connection.openStream())
80+
},
81+
quicDatagrams = connection,
82+
)
83+
session.use {
84+
val target = (Target.ofIpv4(byteArrayOf(127, 0, 0, 1), ORIGIN_PORT) as DecodeResult.Ok).value
85+
86+
// Authenticate, by doing the only thing that authenticates.
87+
val first = session.openFlow(target, FlowKind.Tcp)
88+
assertTrue("the session never authenticated: ${first.reasonOrNull()?.detail}", first is DecodeResult.Ok)
89+
(first as DecodeResult.Ok).value.close()
90+
91+
Thread.sleep(IDLE_WAIT_MILLIS)
92+
93+
val second = session.openFlow(target, FlowKind.Tcp)
94+
assertTrue(
95+
"the connection did not survive ${IDLE_WAIT_MILLIS}ms of quiet " +
96+
"(idle timeout $idle, keep-alive ${connection.keepAliveInterval}): " +
97+
"${second.reasonOrNull()?.detail}",
98+
second is DecodeResult.Ok,
99+
)
100+
(second as DecodeResult.Ok).value.close()
101+
}
102+
}
103+
}
104+
105+
private fun withConnection(body: (QuicConnection) -> Unit) {
106+
val portal = E2eEnvironment.requirePortal()
107+
QuicConnection
108+
.open(
109+
remote = InetSocketAddress(portal.substringBeforeLast(':'), portal.substringAfterLast(':').toInt()),
110+
alpn = "now/1",
111+
serverName = null,
112+
protect = { true },
113+
).use { connection ->
114+
connection.completeHandshake()
115+
body(connection)
116+
}
117+
}
118+
119+
private fun key() =
120+
(
121+
eu.nodepass.somewhere.protocol.auth.SharedKey
122+
.of(E2eEnvironment.sharedKey) as DecodeResult.Ok
123+
).value
124+
125+
private companion object {
126+
const val ORIGIN_PORT = 28091
127+
128+
/**
129+
* Past upstream's five-second handshake deadline and past any
130+
* plausible per-connection idle, without making a suite wait out the
131+
* two-minute default for the sake of one claim.
132+
*/
133+
const val IDLE_WAIT_MILLIS = 45_000L
134+
}
135+
}

‎app/src/main/jni/quic/quic_conn_jni.c‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -538,6 +538,13 @@ static int quic_init(struct quic_conn *c) {
538538
settings.initial_ts = now_ns();
539539

540540
ngtcp2_transport_params_default(&params);
541+
542+
// Announced so that a quiet connection is closed rather than held open for
543+
// ever by whichever end forgets it first. Two minutes matches upstream's
544+
// own default (`NOW_UDP_IDLE_TIMEOUT`), and the effective timeout is the
545+
// smaller of the two ends' values -- so announcing ours is how this client
546+
// gets a say rather than inheriting whatever the Portal chose.
547+
params.max_idle_timeout = 120 * NGTCP2_SECONDS;
541548
// NW-P-19: only one bidirectional stream is credited before
542549
// authentication, and unidirectional streams are never used. Advertising
543550
// none of the latter says so on the wire rather than in a comment.
@@ -1235,6 +1242,61 @@ Java_eu_nodepass_somewhere_quic_QuicConnection_nativeMaxDatagram(JNIEnv *env,
12351242
return (jint)usable;
12361243
}
12371244

1245+
/*
1246+
* The idle timeout actually in force, in nanoseconds.
1247+
*
1248+
* The smaller of the two ends' announced values, which is what governs: a
1249+
* client that computed its keep-alive from its own announcement alone would be
1250+
* safe against itself and late against a peer that announced less.
1251+
*
1252+
* Zero means neither end set one, and then nothing closes the connection for
1253+
* being quiet.
1254+
*/
1255+
JNIEXPORT jlong JNICALL
1256+
Java_eu_nodepass_somewhere_quic_QuicConnection_nativeIdleTimeout(JNIEnv *env,
1257+
jclass clazz,
1258+
jlong handle) {
1259+
(void)env;
1260+
(void)clazz;
1261+
int err;
1262+
struct quic_conn *c = checked(handle, &err);
1263+
const ngtcp2_transport_params *local, *remote;
1264+
ngtcp2_duration ours, theirs;
1265+
1266+
if (!c) {
1267+
return err;
1268+
}
1269+
local = ngtcp2_conn_get_local_transport_params(c->conn);
1270+
remote = ngtcp2_conn_get_remote_transport_params(c->conn);
1271+
ours = local ? local->max_idle_timeout : 0;
1272+
theirs = remote ? remote->max_idle_timeout : 0;
1273+
1274+
if (ours == 0) {
1275+
return (jlong)theirs;
1276+
}
1277+
if (theirs == 0) {
1278+
return (jlong)ours;
1279+
}
1280+
return (jlong)(ours < theirs ? ours : theirs);
1281+
}
1282+
1283+
// How often to send a PING on an otherwise quiet connection. Zero disables it.
1284+
JNIEXPORT void JNICALL
1285+
Java_eu_nodepass_somewhere_quic_QuicConnection_nativeSetKeepAlive(JNIEnv *env,
1286+
jclass clazz,
1287+
jlong handle,
1288+
jlong nanos) {
1289+
(void)env;
1290+
(void)clazz;
1291+
int err;
1292+
struct quic_conn *c = checked(handle, &err);
1293+
if (!c) {
1294+
return;
1295+
}
1296+
ngtcp2_conn_set_keep_alive_timeout(
1297+
c->conn, nanos <= 0 ? UINT64_MAX : (ngtcp2_duration)nanos);
1298+
}
1299+
12381300
JNIEXPORT jstring JNICALL
12391301
Java_eu_nodepass_somewhere_quic_QuicConnection_nativeLastMessage(JNIEnv *env,
12401302
jclass clazz,

‎app/src/main/kotlin/eu/nodepass/somewhere/protocol/session/QuicCarrier.kt‎

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,22 @@ class QuicCarrier(
9696
/** Opens one client-initiated bidirectional stream as a byte transport. */
9797
fun interface StreamFactory {
9898
fun open(): Transport
99+
100+
/**
101+
* Which connection the last [open] came from.
102+
*
103+
* A QUIC connection authenticates once — but *once per connection*, and
104+
* a connection that died and was rebuilt is a new one that has never
105+
* authenticated. Without this the carrier would remember having
106+
* authenticated and send the first flow on the new connection with no
107+
* AuthFrame, which a Portal answers with the silence it answers every
108+
* unauthenticated flow with: a tunnel that survives a network change
109+
* and then carries nothing.
110+
*
111+
* A factory that never rebuilds returns a constant, which is why this
112+
* has a default.
113+
*/
114+
fun generation(): Int = 0
99115
}
100116

101117
/** The unreliable half of a QUIC connection, as this carrier needs it. */
@@ -108,15 +124,16 @@ class QuicCarrier(
108124
fun maxDatagram(): Int
109125
}
110126

111-
private var authenticated = false
127+
/** The generation this carrier last offered an AuthFrame on, or null. */
128+
private var authenticatedOn: Int? = null
112129
private var closed = false
113130
private val open = mutableListOf<Transport>()
114131

115132
/** Section 9's UDP carriage, when this connection has a datagram side. */
116133
private val lane: DatagramLane? = datagrams?.let { DatagramLane(it) }
117134

118-
/** Whether this connection has already offered its AuthFrame. */
119-
val hasAuthenticated: Boolean get() = authenticated
135+
/** Whether the current connection has already offered its AuthFrame. */
136+
val hasAuthenticated: Boolean get() = authenticatedOn == streams.generation()
120137

121138
fun openFlow(
122139
target: Target,
@@ -147,9 +164,10 @@ class QuicCarrier(
147164
is DecodeResult.Ok -> built.value
148165
}
149166

150-
// Exactly once per connection, and before anything else on the wire.
167+
// Exactly once per connection — and a rebuilt connection is a new one.
168+
val generation = streams.generation()
151169
val authFrame =
152-
if (authenticated) {
170+
if (authenticatedOn == generation) {
153171
ByteArray(0)
154172
} else {
155173
Authentication.encodeFrame(
@@ -159,7 +177,7 @@ class QuicCarrier(
159177
sessionId = sessionId.toByteArray(),
160178
)
161179
}
162-
authenticated = true
180+
authenticatedOn = generation
163181

164182
// A UDP flow's payload does not ride the stream. Section 9 is explicit:
165183
// after READY on the control stream, packets travel as DATAGRAMs — so
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// SPDX-License-Identifier: GPL-3.0-only
2+
// Copyright (C) 2026 The Somewhere Authors
3+
4+
package eu.nodepass.somewhere.quic
5+
6+
import kotlin.time.Duration
7+
import kotlin.time.Duration.Companion.seconds
8+
9+
/**
10+
* How often an idle QUIC connection says something, so that it stays open.
11+
*
12+
* ## Why this is derived rather than fixed
13+
*
14+
* The reference client sends a keep-alive every fifteen seconds
15+
* (`src/vector/session/quic.rs`, `Duration::from_secs(15)`), against an idle
16+
* timeout that defaults to two minutes. On a server that costs nothing. On a
17+
* phone it is a radio wake every fifteen seconds for the life of the tunnel,
18+
* and the radio is the expensive part of a mobile connection by a wide margin —
19+
* an idle modem that is woken often never reaches its low-power state at all.
20+
*
21+
* So the interval is read off the timeout that is actually in force. The peer
22+
* announces `max_idle_timeout` in its transport parameters and the effective
23+
* timeout is the smaller of the two ends' values; a keep-alive at half of that
24+
* leaves a full interval of margin, which is one whole lost packet's worth.
25+
*
26+
* ## The two bounds, and why each exists
27+
*
28+
* A **floor** because a peer announcing a very short timeout would otherwise
29+
* produce a keep-alive storm; below the floor the connection is simply allowed
30+
* to close and be rebuilt, which costs one handshake rather than a permanently
31+
* busy radio.
32+
*
33+
* A **ceiling** because middleboxes forget UDP mappings long before QUIC
34+
* forgets connections. Thirty seconds is the interval below which most NAT
35+
* bindings survive, and a keep-alive that outlived the binding would be sent
36+
* into a path that no longer exists.
37+
*
38+
* Nothing here is negotiated or sent: a keep-alive interval is a local decision
39+
* about when to send PING, and the specification says nothing about it.
40+
*/
41+
internal object KeepAlive {
42+
/** The reference client's fixed interval, kept as the thing to beat. */
43+
val UPSTREAM_INTERVAL: Duration = 15.seconds
44+
45+
/** Below this a connection is left to close and be rebuilt. */
46+
val FLOOR: Duration = 5.seconds
47+
48+
/** Above this a NAT binding is more likely to have been forgotten. */
49+
val CEILING: Duration = 30.seconds
50+
51+
/**
52+
* The interval for a connection whose effective idle timeout is
53+
* [idleTimeout], or null when no keep-alive should be sent at all.
54+
*
55+
* @param idleTimeout the smaller of the two ends' `max_idle_timeout`, which
56+
* is what actually governs. Zero means neither end set one, and then
57+
* nothing will close the connection for being quiet.
58+
*/
59+
fun interval(idleTimeout: Duration): Duration? {
60+
if (idleTimeout <= Duration.ZERO) return null
61+
val half = idleTimeout / 2
62+
if (half < FLOOR) return null
63+
return if (half > CEILING) CEILING else half
64+
}
65+
66+
/**
67+
* Whether [interval] is safe for [idleTimeout].
68+
*
69+
* Stated separately from [interval] because it is the property that matters
70+
* and it should be checkable against any value, not only the one this
71+
* object computes.
72+
*/
73+
fun staysInside(
74+
interval: Duration,
75+
idleTimeout: Duration,
76+
): Boolean = interval > Duration.ZERO && interval < idleTimeout
77+
}

0 commit comments

Comments
 (0)