Skip to content

Commit 32bce63

Browse files
committed
Run the differential over all five carrier combinations
Both implementations behaved identically on every case, over tcp/tcp, tcp/tcp with Mux, udp/udp, udp/tcp and tcp/udp. That is what row 25 asked for, and it is where this project has twice found a protocol fact that had grown a second shape the moment a second carrier landed. The differential is a JVM test and QUIC lived only in an Android library, so build-host-quic.sh compiles the same JNI sources the app ships against the build machine. lwIP stays out of it: it is built NO_SYS against an Android port and has no business running on a host, while the bridge depends on nothing Android at all. The QUIC rows are skipped, loudly, when no bridge was built — two pairs reported as four would be worse than two reported as two. Split flows needed something the duplex ones did not, and it is the most interesting thing here: **the framing of a UDP flow belongs to a direction, not to a flow.** With up=udp&down=tcp the uplink sends DATAGRAMs while the downlink is a TLS stream carrying length-prefixed packets, and with up=tcp&down=udp it is the other way round. Framing both halves the same way is a flow that opens and then carries nothing, which is exactly how the two split uot_round_trip cases diverged before this. So section 9's carriage moved out of QuicCarrier into DatagramLane, which both carriers use. Writing it twice is how a protocol fact grows a second shape, and the case that would have caught it is the one that found it. One more defect the differential found on its own terms: its UDP case always framed for a stream, so quic_uot_round_trip diverged with an IllegalStateException while the oracle echoed 512 bytes. The case now asks the flow which framing it wants — the same question UdpRelay asks, asked the same way, because a case that framed for one carrier and ran on the other reports a divergence that is only its own assumption. The script's carrier-pairs line stopped saying "tcp/tcp only. up=udp and down=udp select QUIC, which L1 does not implement." It names what was actually run, and still says so when a host has no bridge. L3: twenty-four covered, one partly, two blocked. The two left are rows 26 and 27, and they wait on the same thing — a physical device. Nothing else in L3 is blocked on code. Gates green; device suite 49 cases; differential 27 cases across five carriers.
1 parent 93bc59e commit 32bce63

15 files changed

Lines changed: 809 additions & 197 deletions

File tree

‎app/build.gradle.kts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -590,6 +590,18 @@ val nativeLibraryChecks =
590590

591591
tasks.named("check") { dependsOn(nativeLibraryChecks) }
592592

593+
// The host QUIC bridge, when one was built.
594+
//
595+
// `conformance/scripts/build-host-quic.sh` compiles the same JNI sources the
596+
// app ships against this machine, so the oracle differential can compare the
597+
// two implementations over QUIC without an emulator in the loop. Absent is the
598+
// ordinary case and the QUIC rows are then skipped, loudly.
599+
tasks.withType<Test>().configureEach {
600+
System.getenv("SOMEWHERE_QUIC_LIBRARY")?.takeIf { it.isNotBlank() }?.let {
601+
systemProperty("somewhere.quic.library", it)
602+
}
603+
}
604+
593605
val classpathConsistencyTasks =
594606
listOf("Debug" to "debug", "Release" to "release").map { (capitalised, lowercase) ->
595607
tasks.register<ClasspathConsistencyTask>("checkClasspathConsistency$capitalised") {
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
// SPDX-License-Identifier: GPL-3.0-only
2+
// Copyright (C) 2026 The Somewhere Authors
3+
4+
package eu.nodepass.somewhere.protocol.session
5+
6+
import eu.nodepass.somewhere.protocol.DecodeResult
7+
import eu.nodepass.somewhere.protocol.quic.DatagramFrame
8+
import eu.nodepass.somewhere.protocol.quic.DatagramReassembler
9+
import eu.nodepass.somewhere.protocol.quic.PacketIds
10+
import eu.nodepass.somewhere.protocol.quic.QuicDatagram
11+
import java.util.concurrent.ConcurrentHashMap
12+
import java.util.concurrent.LinkedBlockingQueue
13+
14+
/**
15+
* Section 9's UDP carriage, shared by the carriers that can use it.
16+
*
17+
* ## Why this is not inside one carrier
18+
*
19+
* A duplex QUIC flow sends and receives packets as DATAGRAMs. A **split** flow
20+
* may do it in one direction only: with `up=udp&down=tcp` the uplink is QUIC and
21+
* the downlink is a TLS stream carrying length-prefixed packets, and with
22+
* `up=tcp&down=udp` it is the other way round. So the framing belongs to a
23+
* *direction*, not to a flow, and putting it inside either carrier would mean
24+
* writing it twice — which is how a protocol fact grows a second shape.
25+
*
26+
* ## One demultiplexer
27+
*
28+
* One connection's datagrams carry every flow's traffic, so something has to
29+
* route them and it has to be one thing: two readers pulling from the
30+
* connection would each discard what belonged to the other.
31+
*/
32+
internal class DatagramLane(
33+
private val datagrams: QuicCarrier.Datagrams,
34+
) {
35+
private val inbound = ConcurrentHashMap<UInt, LinkedBlockingQueue<ByteArray>>()
36+
private val packetIds = ConcurrentHashMap<UInt, PacketIds>()
37+
private val reassembler = DatagramReassembler()
38+
private val demultiplex = Any()
39+
40+
/** Row 15: payload is refused until a Portal has answered a flow. */
41+
fun markReady(flowId: UInt) {
42+
inbound.putIfAbsent(flowId, LinkedBlockingQueue())
43+
packetIds.putIfAbsent(flowId, PacketIds())
44+
reassembler.markReady()
45+
}
46+
47+
fun forget(flowId: UInt) {
48+
inbound.remove(flowId)
49+
packetIds.remove(flowId)
50+
}
51+
52+
/** The largest packet one datagram can carry, or 0 when unsupported. */
53+
fun maxDatagram(): Int = datagrams.maxDatagram()
54+
55+
/**
56+
* Sends one packet, fragmenting when the path cannot carry it whole.
57+
*
58+
* Lossy by design: a packet that cannot be planned is dropped, exactly as a
59+
* UDP socket drops one it cannot send. Telling the caller would be adding a
60+
* guarantee the transport underneath does not have.
61+
*/
62+
fun send(
63+
flowId: UInt,
64+
payload: ByteArray,
65+
) {
66+
val maxDatagram = datagrams.maxDatagram()
67+
when (val planned = QuicDatagram.plan(payload.size, maxDatagram)) {
68+
is DecodeResult.Invalid -> Unit
69+
is DecodeResult.Ok ->
70+
if (planned.value == 1) {
71+
datagrams.send(QuicDatagram.Data(flowId, payload).encode())
72+
} else {
73+
// A fresh packet id per packet, and per replan: a peer
74+
// reassembling two layouts under one id would produce a
75+
// packet that is neither.
76+
val packetId = (packetIds[flowId] ?: PacketIds().also { packetIds[flowId] = it }).allocate()
77+
val perFragment = maxDatagram - QuicDatagram.FRAGMENT_HEADER_SIZE
78+
for (index in 0 until planned.value) {
79+
val from = index * perFragment
80+
val to = minOf(from + perFragment, payload.size)
81+
datagrams.send(
82+
QuicDatagram
83+
.Fragment(
84+
flowId = flowId,
85+
packetId = packetId,
86+
index = index,
87+
count = planned.value,
88+
totalLength = payload.size,
89+
payload = payload.copyOfRange(from, to),
90+
).encode(),
91+
)
92+
}
93+
}
94+
}
95+
}
96+
97+
/** Waits up to [timeoutMillis] for one packet on [flowId]. */
98+
fun receive(
99+
flowId: UInt,
100+
timeoutMillis: Long,
101+
): ByteArray? {
102+
val queue = inbound[flowId] ?: return null
103+
val deadline = System.nanoTime() + timeoutMillis * NANOS_PER_MILLI
104+
while (true) {
105+
queue.poll()?.let { return if (it === CLOSED) null else it }
106+
if (System.nanoTime() >= deadline) return null
107+
pump(PUMP_MILLIS)
108+
}
109+
}
110+
111+
fun close(flowId: UInt) {
112+
runCatching { datagrams.send(QuicDatagram.Close(flowId).encode()) }
113+
forget(flowId)
114+
}
115+
116+
/**
117+
* Takes one datagram and gives it to whoever it is for.
118+
*
119+
* Under a lock across a single receive-and-route, so a flow waiting for its
120+
* own packet does not hold up one whose packet has already arrived.
121+
*/
122+
private fun pump(timeoutMillis: Long) {
123+
synchronized(demultiplex) {
124+
val raw = datagrams.receive(timeoutMillis) ?: return
125+
val frame =
126+
when (val decoded = DatagramFrame.decode(raw)) {
127+
is DecodeResult.Ok -> decoded.value
128+
// One unreliable packet. Tearing down every flow on the
129+
// connection because of it would turn a loss into an outage.
130+
is DecodeResult.Invalid -> return
131+
}
132+
when (val accepted = reassembler.offer(frame, System.currentTimeMillis())) {
133+
is DecodeResult.Ok ->
134+
when (val value = accepted.value) {
135+
is DatagramReassembler.Accepted.Payload -> inbound[value.flowId]?.offer(value.bytes)
136+
is DatagramReassembler.Accepted.Closed -> inbound[value.flowId]?.offer(CLOSED)
137+
DatagramReassembler.Accepted.Pending -> Unit
138+
}
139+
is DecodeResult.Invalid -> Unit
140+
}
141+
}
142+
}
143+
144+
private companion object {
145+
/** "the far end closed this flow", not a payload. */
146+
val CLOSED = ByteArray(0)
147+
const val PUMP_MILLIS = 25L
148+
const val NANOS_PER_MILLI = 1_000_000L
149+
}
150+
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ class NowhereSession(
105105
*/
106106
private val split: SplitCarrier? =
107107
if (splitUplink != null && splitDownlink != null) {
108-
SplitCarrier(splitUplink, splitDownlink, sharedKey, id)
108+
SplitCarrier(splitUplink, splitDownlink, sharedKey, id, quicDatagrams)
109109
} else {
110110
null
111111
}

0 commit comments

Comments
 (0)