Mult Protocol
This commit is contained in:
@@ -386,25 +386,25 @@ front end never knows it is talking to a record protocol. It holds:
|
|||||||
* `downloadOffset` — next byte to request,
|
* `downloadOffset` — next byte to request,
|
||||||
* `consumedOffset` — next unread byte, sent as `ack`,
|
* `consumedOffset` — next unread byte, sent as `ack`,
|
||||||
* `readBuf` — received but not yet delivered to the reader,
|
* `readBuf` — received but not yet delivered to the reader,
|
||||||
* two independent `requestLane`s, one for uploads and one for downloads, so a
|
* two `requestLane`s of its own, one for uploads and one for downloads, so a
|
||||||
blocking download poll never delays an upload.
|
blocking download poll never delays an upload.
|
||||||
|
|
||||||
`Write` slices the caller's buffer into records of the current upload size, each
|
`Write` slices the caller's buffer into records of the current upload size, each
|
||||||
acknowledged before the next is sent. `Read` refills `readBuf` via
|
acknowledged before the next is sent. `Read` refills `readBuf` via
|
||||||
`fillReadBuffer`, which issues batched download requests. `Close` sends
|
`fillReadBuffer`, which issues batched download requests. `Close` sends
|
||||||
`ModeClose` on a throwaway lane and closes both persistent lanes.
|
`ModeClose` over the upload lane, then closes both lanes.
|
||||||
|
|
||||||
### 5.3 Request lanes and connection reuse
|
### 5.3 Request lanes and connection reuse
|
||||||
|
|
||||||
A `requestLane` owns at most one physical TCP connection and serialises requests
|
A `requestLane` owns at most one physical TCP connection and serialises requests
|
||||||
onto it under a mutex. Any I/O error discards the connection; the next request
|
onto it under a mutex. Each tunnel has two of them (§5.7). Any I/O error discards
|
||||||
redials. Sockets get `TCP_NODELAY`, 30-second keepalives, and optionally explicit
|
the connection; the next request redials. Sockets get `TCP_NODELAY`, 30-second
|
||||||
buffer sizes via `--tcp-buffer`.
|
keepalives, and optionally explicit buffer sizes via `--tcp-buffer`.
|
||||||
|
|
||||||
| `--chunk-reconnect-every` | Behaviour |
|
| `--chunk-reconnect-every` | Behaviour |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `0` | Persistent — one connection for the life of the lane |
|
| `0` | Persistent — one connection for the life of the lane |
|
||||||
| `1` | Auto — persistent if the path probe showed reuse works, otherwise one logical request per connection |
|
| `1` (default) | Auto — persistent if the path probe showed reuse works, otherwise one logical request per connection. Resolved silently, since it runs once per flow |
|
||||||
| `N ≥ 2` | Rotate — close and redial after N logical requests |
|
| `N ≥ 2` | Rotate — close and redial after N logical requests |
|
||||||
|
|
||||||
### 5.4 Path probing
|
### 5.4 Path probing
|
||||||
@@ -510,7 +510,33 @@ count = max(count, minPipeline) // the floor always wins
|
|||||||
The server independently clamps `count` to 256 and `limit` to its own
|
The server independently clamps `count` to 256 and `limit` to its own
|
||||||
`--chunk-max`, so a client can never demand more than the server allows.
|
`--chunk-max`, so a client can never demand more than the server allows.
|
||||||
|
|
||||||
### 5.7 Failure escalation
|
### 5.7 Connection model
|
||||||
|
|
||||||
|
Each proxied socket gets its own tunnel, and each tunnel dials **two** TCP
|
||||||
|
connections to the server: one upload lane and one download lane. `OPEN` and
|
||||||
|
`CLOSE` ride the upload lane rather than dialling their own connections.
|
||||||
|
|
||||||
|
So a device browsing normally holds roughly `2 × active flows` connections to
|
||||||
|
port 53, plus churn as flows come and go. That is the transport behaving as a
|
||||||
|
proxy, not as a single multiplexed link.
|
||||||
|
|
||||||
|
**Why it is not one connection.** A response header is only `status + length` —
|
||||||
|
it carries no session or request ID. Responses can therefore only be matched to
|
||||||
|
requests by **arrival order**, which means a connection must finish one full
|
||||||
|
exchange before another session may use it. Since a download is a long poll that
|
||||||
|
can block for `--chunk-poll-wait`, sharing one connection across sessions lets
|
||||||
|
idle pollers starve real traffic. Per-tunnel lanes are a requirement of the
|
||||||
|
current wire format, not an oversight.
|
||||||
|
|
||||||
|
Making the client hold a single connection would require adding a session ID to
|
||||||
|
the response header, demultiplexing responses asynchronously on the client, and
|
||||||
|
handling requests concurrently per connection on the server — a wire-format
|
||||||
|
change affecting both ends.
|
||||||
|
|
||||||
|
`--chunk-pollers` is accepted for compatibility and validated to 1–128, but the
|
||||||
|
transport uses one download worker per tunnel and never reads it.
|
||||||
|
|
||||||
|
### 5.8 Failure escalation
|
||||||
|
|
||||||
On a download failure the client escalates in a fixed order:
|
On a download failure the client escalates in a fixed order:
|
||||||
|
|
||||||
@@ -800,15 +826,14 @@ checksum of zero is written as `0xFFFF` per RFC 768.
|
|||||||
| Min chunk | 32 | `--chunk-min` |
|
| Min chunk | 32 | `--chunk-min` |
|
||||||
| Batch max | 1 | `--chunk-concurrency` |
|
| Batch max | 1 | `--chunk-concurrency` |
|
||||||
| Batch min | 1 | `--chunk-concurrency-min` |
|
| Batch min | 1 | `--chunk-concurrency-min` |
|
||||||
| Reconnect every | 0 | `--chunk-reconnect-every` |
|
| Reconnect every | 1 (auto) | `--chunk-reconnect-every` |
|
||||||
| Timeout (s) | 2 | `--chunk-timeout` |
|
| Timeout (s) | 2 | `--chunk-timeout` |
|
||||||
|
|
||||||
Fixed by the service: `--listen-host 127.0.0.1`, `--listen-port 8080`,
|
Fixed by the service: `--listen-host 127.0.0.1`, `--listen-port 8080`,
|
||||||
`--transport chunk`, `--chunk-pollers 1`, `--chunk-grow-after 16`,
|
`--transport chunk`, `--chunk-grow-after 16`, `--chunk-adapt-log=true`.
|
||||||
`--chunk-adapt-log=true`.
|
|
||||||
|
|
||||||
Settings are laid out in four cards: CONNECTION, RECORD SIZE, DOWNLOAD BATCH, and
|
Settings are laid out in four cards: CONNECTION, RECORD SIZE, DOWNLOAD BATCH,
|
||||||
ADVANCED. The batch card carries a live hint that restates the current setting in
|
and ADVANCED. The batch card carries a live hint that restates the current setting in
|
||||||
words as you type, so the mode is never ambiguous:
|
words as you type, so the mode is never ambiguous:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -827,7 +852,8 @@ Download batch: pinned at 5 records per request (never adapts)
|
|||||||
```
|
```
|
||||||
|
|
||||||
Validation ranges: port 1–65535, max chunk 32–1048576, min chunk 32–max chunk,
|
Validation ranges: port 1–65535, max chunk 32–1048576, min chunk 32–max chunk,
|
||||||
batch values 1–256 with `min ≤ max`, reconnect 0–1000000, timeout 1–120.
|
batch values 1–256 with `min ≤ max`, reconnect 0–1000000,
|
||||||
|
timeout 1–120.
|
||||||
|
|
||||||
### 7.10 Logs
|
### 7.10 Logs
|
||||||
|
|
||||||
@@ -1013,7 +1039,8 @@ Max chunk: 1048576
|
|||||||
Min chunk: 32
|
Min chunk: 32
|
||||||
Batch max: 1
|
Batch max: 1
|
||||||
Batch min: 1
|
Batch min: 1
|
||||||
Reconnect every: 0
|
Pollers: 1
|
||||||
|
Reconnect every: 1
|
||||||
Timeout (s): 2
|
Timeout (s): 2
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1065,7 +1092,7 @@ Use **OPEN LOGS** to watch the path probe and any adaptation.
|
|||||||
| `--chunk-reconnect-every` | `0` | 0 persistent, 1 auto, N rotate |
|
| `--chunk-reconnect-every` | `0` | 0 persistent, 1 auto, N rotate |
|
||||||
| `--chunk-poll-delay` | `2ms` | Pause after an empty poll |
|
| `--chunk-poll-delay` | `2ms` | Pause after an empty poll |
|
||||||
| `--chunk-timeout` | `2s` | Per-record transaction timeout |
|
| `--chunk-timeout` | `2s` | Per-record transaction timeout |
|
||||||
| `--chunk-pollers` | `1` | Reserved compatibility knob; accepted but unused |
|
| `--chunk-pollers` | `1` | Accepted for compatibility; validated 1–128 but unused |
|
||||||
|
|
||||||
The `concurrency` flag names are historical. They control the download **batch
|
The `concurrency` flag names are historical. They control the download **batch
|
||||||
depth** described in §5.6, not any form of threading.
|
depth** described in §5.6, not any form of threading.
|
||||||
@@ -1099,6 +1126,15 @@ manual tuning makes things worse.
|
|||||||
1. More records per request is the main lever when latency to the server is
|
1. More records per request is the main lever when latency to the server is
|
||||||
high, because each round trip returns more data. If the log repeatedly shows
|
high, because each round trip returns more data. If the log repeatedly shows
|
||||||
`adaptive download batch: N -> N/2`, the path cannot sustain that depth.
|
`adaptive download batch: N -> N/2`, the path cannot sustain that depth.
|
||||||
|
* **Streams die mid-transfer, or nothing loads at all.** Set `Reconnect every`
|
||||||
|
to `1` (auto). `0` forces persistent connections, and many networks silently
|
||||||
|
kill long-lived port-53 connections; auto probes first and falls back to one
|
||||||
|
logical request per connection when persistence does not survive. This is the
|
||||||
|
single most important setting on a restrictive path.
|
||||||
|
* **Logs show the same transition many times over (`128 -> 64` repeatedly).**
|
||||||
|
Each tunnel adapts independently, so a burst of flows produces a burst of
|
||||||
|
identical lines. The app collapses consecutive duplicates into a counted line;
|
||||||
|
the underlying behaviour is normal.
|
||||||
* **The batch keeps collapsing to 1 and throughput dies with it.** Some paths only
|
* **The batch keeps collapsing to 1 and throughput dies with it.** Some paths only
|
||||||
deliver correctly at one specific number of records. Try `Batch max = Batch min
|
deliver correctly at one specific number of records. Try `Batch max = Batch min
|
||||||
= N` for a few values of N and leave it pinned at whichever works. Pinned mode
|
= N` for a few values of N and leave it pinned at whichever works. Pinned mode
|
||||||
|
|||||||
+4
-4
@@ -1,4 +1,4 @@
|
|||||||
4e4a16f6c537f2c6be5c104f6e50ea907122092a21402a70506fe12e6f144bb7 bin/dragontcp-hybrid-server-linux-amd64
|
56707362bae6b388795150a77b27a14de046a56e05037485d5dfb4bb7db0f8b9 *bin/dragontcp-hybrid-server-linux-amd64
|
||||||
39a6bb16cbddefb50e791220747f5cd01b0c894992c657b24c6c2e5d1501299f bin/dragontcp-hybrid-server-linux-arm64
|
35e7dbbb84bbb76b0eea052eff18d58e24c1fa1c43274aa6c1a5c686a1d38f40 *bin/dragontcp-hybrid-server-linux-arm64
|
||||||
66ad3741d84b4e73098912725af762824bec629adab5522a5e161b4e23b79dee bin/dragontcp-hybrid-client-linux-amd64
|
2f820ce82a65c285684c875afd0311f1d62afe856f8b878eab682f9864252a19 *bin/dragontcp-hybrid-client-linux-amd64
|
||||||
fa6b41cc8c2999932cc78f731cb249cab677af9df7701c491fb688f9cbcb089d android/lib/arm64-v8a/libdragontcp_client.so
|
1b5bcf4d3a446cec397206557fa6f1a4c229b11b8e871a7a1072ac3ae9f81e31 *android/lib/arm64-v8a/libdragontcp_client.so
|
||||||
|
|||||||
Binary file not shown.
@@ -11,6 +11,8 @@ public final class AppLog {
|
|||||||
private static final int MAX_LINES = 600;
|
private static final int MAX_LINES = 600;
|
||||||
private static final ArrayDeque<String> lines = new ArrayDeque<>();
|
private static final ArrayDeque<String> lines = new ArrayDeque<>();
|
||||||
private static final CopyOnWriteArrayList<Listener> listeners = new CopyOnWriteArrayList<>();
|
private static final CopyOnWriteArrayList<Listener> listeners = new CopyOnWriteArrayList<>();
|
||||||
|
private static String lastLine;
|
||||||
|
private static int repeatCount;
|
||||||
|
|
||||||
private AppLog() {}
|
private AppLog() {}
|
||||||
|
|
||||||
@@ -18,10 +20,36 @@ public final class AppLog {
|
|||||||
if (line == null) return;
|
if (line == null) return;
|
||||||
line = line.trim();
|
line = line.trim();
|
||||||
if (line.isEmpty()) return;
|
if (line.isEmpty()) return;
|
||||||
|
|
||||||
|
// The core emits one adaptation line per proxied flow, so a busy page
|
||||||
|
// load produces dozens of identical entries. Collapse consecutive
|
||||||
|
// repeats into a single counted line instead of burying the log.
|
||||||
|
String emit;
|
||||||
synchronized (lines) {
|
synchronized (lines) {
|
||||||
|
if (line.equals(lastLine)) {
|
||||||
|
repeatCount++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (repeatCount > 0) {
|
||||||
|
String summary = " (previous line repeated " + repeatCount + " more times)";
|
||||||
|
repeatCount = 0;
|
||||||
|
push(summary);
|
||||||
|
notifyListeners(summary);
|
||||||
|
}
|
||||||
|
lastLine = line;
|
||||||
|
push(line);
|
||||||
|
emit = line;
|
||||||
|
}
|
||||||
|
notifyListeners(emit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Caller must hold the {@code lines} lock. */
|
||||||
|
private static void push(String line) {
|
||||||
while (lines.size() >= MAX_LINES) lines.removeFirst();
|
while (lines.size() >= MAX_LINES) lines.removeFirst();
|
||||||
lines.addLast(line);
|
lines.addLast(line);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void notifyListeners(String line) {
|
||||||
for (Listener listener : listeners) {
|
for (Listener listener : listeners) {
|
||||||
try { listener.onLine(line); } catch (Throwable ignored) {}
|
try { listener.onLine(line); } catch (Throwable ignored) {}
|
||||||
}
|
}
|
||||||
@@ -36,7 +64,11 @@ public final class AppLog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static void clear() {
|
public static void clear() {
|
||||||
synchronized (lines) { lines.clear(); }
|
synchronized (lines) {
|
||||||
|
lines.clear();
|
||||||
|
lastLine = null;
|
||||||
|
repeatCount = 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void addListener(Listener listener) { listeners.addIfAbsent(listener); }
|
public static void addListener(Listener listener) { listeners.addIfAbsent(listener); }
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ public class DragonService extends VpnService {
|
|||||||
public static final String EXTRA_SERVER = "server";
|
public static final String EXTRA_SERVER = "server";
|
||||||
public static final String EXTRA_PORT = "port";
|
public static final String EXTRA_PORT = "port";
|
||||||
public static final String EXTRA_TOKEN = "token";
|
public static final String EXTRA_TOKEN = "token";
|
||||||
|
/** Wire format: "auto", "b" or "x". */
|
||||||
|
public static final String EXTRA_WIRE = "wire";
|
||||||
public static final String EXTRA_CHUNK_MAX = "chunkMax";
|
public static final String EXTRA_CHUNK_MAX = "chunkMax";
|
||||||
public static final String EXTRA_CHUNK_MIN = "chunkMin";
|
public static final String EXTRA_CHUNK_MIN = "chunkMin";
|
||||||
/** Maximum download records per request. A transport setting, not a thread count. */
|
/** Maximum download records per request. A transport setting, not a thread count. */
|
||||||
@@ -110,11 +112,12 @@ public class DragonService extends VpnService {
|
|||||||
String server = intent.getStringExtra(EXTRA_SERVER);
|
String server = intent.getStringExtra(EXTRA_SERVER);
|
||||||
int port = intent.getIntExtra(EXTRA_PORT, 53);
|
int port = intent.getIntExtra(EXTRA_PORT, 53);
|
||||||
String token = intent.getStringExtra(EXTRA_TOKEN);
|
String token = intent.getStringExtra(EXTRA_TOKEN);
|
||||||
|
String wire = intent.getStringExtra(EXTRA_WIRE);
|
||||||
int chunkMax = intent.getIntExtra(EXTRA_CHUNK_MAX, 1024 * 1024);
|
int chunkMax = intent.getIntExtra(EXTRA_CHUNK_MAX, 1024 * 1024);
|
||||||
int chunkMin = intent.getIntExtra(EXTRA_CHUNK_MIN, 32);
|
int chunkMin = intent.getIntExtra(EXTRA_CHUNK_MIN, 32);
|
||||||
int batchMax = intent.getIntExtra(EXTRA_BATCH_MAX, 1);
|
int batchMax = intent.getIntExtra(EXTRA_BATCH_MAX, 1);
|
||||||
int batchMin = intent.getIntExtra(EXTRA_BATCH_MIN, 1);
|
int batchMin = intent.getIntExtra(EXTRA_BATCH_MIN, 1);
|
||||||
int reconnect = intent.getIntExtra(EXTRA_RECONNECT, 0);
|
int reconnect = intent.getIntExtra(EXTRA_RECONNECT, 1);
|
||||||
int timeout = intent.getIntExtra(EXTRA_TIMEOUT, 2);
|
int timeout = intent.getIntExtra(EXTRA_TIMEOUT, 2);
|
||||||
|
|
||||||
if (server == null || server.trim().isEmpty()) {
|
if (server == null || server.trim().isEmpty()) {
|
||||||
@@ -123,6 +126,7 @@ public class DragonService extends VpnService {
|
|||||||
}
|
}
|
||||||
server = server.trim();
|
server = server.trim();
|
||||||
if (token == null) token = "";
|
if (token == null) token = "";
|
||||||
|
if (wire == null || !(wire.equals("b") || wire.equals("x"))) wire = "auto";
|
||||||
chunkMax = Math.max(32, Math.min(1024 * 1024, chunkMax));
|
chunkMax = Math.max(32, Math.min(1024 * 1024, chunkMax));
|
||||||
chunkMin = Math.max(32, Math.min(chunkMax, chunkMin));
|
chunkMin = Math.max(32, Math.min(chunkMax, chunkMin));
|
||||||
batchMax = Math.max(1, Math.min(BATCH_LIMIT, batchMax));
|
batchMax = Math.max(1, Math.min(BATCH_LIMIT, batchMax));
|
||||||
@@ -133,7 +137,8 @@ public class DragonService extends VpnService {
|
|||||||
try {
|
try {
|
||||||
AppLog.append("Starting DragonTCP → " + server + ":" + port);
|
AppLog.append("Starting DragonTCP → " + server + ":" + port);
|
||||||
AppLog.append(describeBatch(batchMin, batchMax));
|
AppLog.append(describeBatch(batchMin, batchMax));
|
||||||
Process process = startDragonCore(server, port, token, chunkMax, chunkMin, batchMax, batchMin, reconnect, timeout);
|
AppLog.append(describeWire(wire));
|
||||||
|
Process process = startDragonCore(server, port, token, chunkMax, chunkMin, batchMax, batchMin, wire, reconnect, timeout);
|
||||||
synchronized (stateLock) { coreProcess = process; }
|
synchronized (stateLock) { coreProcess = process; }
|
||||||
|
|
||||||
startCoreLogReader(process);
|
startCoreLogReader(process);
|
||||||
@@ -199,6 +204,7 @@ public class DragonService extends VpnService {
|
|||||||
int chunkMin,
|
int chunkMin,
|
||||||
int batchMax,
|
int batchMax,
|
||||||
int batchMin,
|
int batchMin,
|
||||||
|
String wire,
|
||||||
int reconnect,
|
int reconnect,
|
||||||
int timeout
|
int timeout
|
||||||
) throws Exception {
|
) throws Exception {
|
||||||
@@ -217,6 +223,7 @@ public class DragonService extends VpnService {
|
|||||||
cmd.add("--chunk-min"); cmd.add(Integer.toString(chunkMin));
|
cmd.add("--chunk-min"); cmd.add(Integer.toString(chunkMin));
|
||||||
cmd.add("--chunk-max"); cmd.add(Integer.toString(chunkMax));
|
cmd.add("--chunk-max"); cmd.add(Integer.toString(chunkMax));
|
||||||
cmd.add("--chunk-pollers"); cmd.add("1");
|
cmd.add("--chunk-pollers"); cmd.add("1");
|
||||||
|
cmd.add("--wire"); cmd.add(wire);
|
||||||
cmd.add("--chunk-concurrency"); cmd.add(Integer.toString(batchMax));
|
cmd.add("--chunk-concurrency"); cmd.add(Integer.toString(batchMax));
|
||||||
cmd.add("--chunk-concurrency-min"); cmd.add(Integer.toString(batchMin));
|
cmd.add("--chunk-concurrency-min"); cmd.add(Integer.toString(batchMin));
|
||||||
cmd.add("--chunk-reconnect-every"); cmd.add(Integer.toString(reconnect));
|
cmd.add("--chunk-reconnect-every"); cmd.add(Integer.toString(reconnect));
|
||||||
@@ -239,6 +246,14 @@ public class DragonService extends VpnService {
|
|||||||
return "Download batch: adaptive " + min + "-" + max + " records per request";
|
return "Download batch: adaptive " + min + "-" + max + " records per request";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** Human-readable summary of the wire selection, for the log screen. */
|
||||||
|
private static String describeWire(String wire) {
|
||||||
|
if ("b".equals(wire)) return "Wire: B (manual)";
|
||||||
|
if ("x".equals(wire)) return "Wire: X (manual)";
|
||||||
|
return "Wire: auto (probing)";
|
||||||
|
}
|
||||||
|
|
||||||
private void startCoreLogReader(Process process) {
|
private void startCoreLogReader(Process process) {
|
||||||
Thread reader = new Thread(() -> {
|
Thread reader = new Thread(() -> {
|
||||||
try (BufferedReader br = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
|
try (BufferedReader br = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
|
||||||
@@ -246,7 +261,7 @@ public class DragonService extends VpnService {
|
|||||||
while ((line = br.readLine()) != null) {
|
while ((line = br.readLine()) != null) {
|
||||||
// Keep the UI useful: adaptation changes and real errors only.
|
// Keep the UI useful: adaptation changes and real errors only.
|
||||||
String lower = line.toLowerCase();
|
String lower = line.toLowerCase();
|
||||||
if (line.startsWith("adaptive ") || line.startsWith("path probe:") || lower.contains("error") || lower.contains("failed")) {
|
if (line.startsWith("adaptive ") || line.startsWith("path probe:") || line.startsWith("wire") || lower.contains("error") || lower.contains("failed")) {
|
||||||
AppLog.append(line);
|
AppLog.append(line);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,9 +53,14 @@ public class MainActivity extends Activity {
|
|||||||
private EditText batchMax;
|
private EditText batchMax;
|
||||||
private EditText batchMin;
|
private EditText batchMin;
|
||||||
private EditText reconnect;
|
private EditText reconnect;
|
||||||
|
private Button wireAuto;
|
||||||
|
private Button wireB;
|
||||||
|
private Button wireX;
|
||||||
|
private String wireMode = "auto";
|
||||||
private EditText timeout;
|
private EditText timeout;
|
||||||
|
|
||||||
private TextView batchHint;
|
private TextView batchHint;
|
||||||
|
private TextView wireHint;
|
||||||
private Button connectButton;
|
private Button connectButton;
|
||||||
private Button stopButton;
|
private Button stopButton;
|
||||||
private Button logsButton;
|
private Button logsButton;
|
||||||
@@ -159,6 +164,27 @@ public class MainActivity extends Activity {
|
|||||||
connectionCard.addView(connectionRow);
|
connectionCard.addView(connectionRow);
|
||||||
settings.addView(connectionCard, cardParams());
|
settings.addView(connectionCard, cardParams());
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- wire
|
||||||
|
LinearLayout wireCard = card("WIRE");
|
||||||
|
wireCard.addView(hint("Networks differ in which mode they pass."));
|
||||||
|
LinearLayout wireRow = row();
|
||||||
|
wireAuto = segmentButton("A");
|
||||||
|
wireB = segmentButton("B");
|
||||||
|
wireX = segmentButton("X");
|
||||||
|
addSegment(wireRow, wireAuto);
|
||||||
|
addSegment(wireRow, wireB);
|
||||||
|
addSegment(wireRow, wireX);
|
||||||
|
wireCard.addView(wireRow);
|
||||||
|
wireHint = text("", 11, MUTED, true);
|
||||||
|
wireHint.setLineSpacing(0, 1.08f);
|
||||||
|
wireHint.setPadding(dp(2), dp(8), dp(2), dp(2));
|
||||||
|
wireCard.addView(wireHint);
|
||||||
|
settings.addView(wireCard, cardParams());
|
||||||
|
|
||||||
|
wireAuto.setOnClickListener(v -> setWireMode("auto"));
|
||||||
|
wireB.setOnClickListener(v -> setWireMode("b"));
|
||||||
|
wireX.setOnClickListener(v -> setWireMode("x"));
|
||||||
|
|
||||||
// --------------------------------------------------------- record size
|
// --------------------------------------------------------- record size
|
||||||
LinearLayout sizeCard = card("RECORD SIZE (BYTES)");
|
LinearLayout sizeCard = card("RECORD SIZE (BYTES)");
|
||||||
LinearLayout chunks = row();
|
LinearLayout chunks = row();
|
||||||
@@ -200,10 +226,12 @@ public class MainActivity extends Activity {
|
|||||||
// ------------------------------------------------------------ advanced
|
// ------------------------------------------------------------ advanced
|
||||||
LinearLayout advancedCard = card("ADVANCED");
|
LinearLayout advancedCard = card("ADVANCED");
|
||||||
LinearLayout timing = row();
|
LinearLayout timing = row();
|
||||||
reconnect = addFieldToRow(timing, "Reconnect every", "0 = persistent", "0", true, false, 0.58f);
|
reconnect = addFieldToRow(timing, "Reconnect every", "1 = auto", "1", true, false, 0.58f);
|
||||||
timeout = addFieldToRow(timing, "Timeout (s)", "2", "2", true, false, 0.42f);
|
timeout = addFieldToRow(timing, "Timeout (s)", "2", "2", true, false, 0.42f);
|
||||||
advancedCard.addView(timing);
|
advancedCard.addView(timing);
|
||||||
advancedCard.addView(hint("0 reconnect = persistent • 1 = auto • N = rotate every N requests"));
|
advancedCard.addView(hint(
|
||||||
|
"1 = auto (recommended: probes the path, falls back to one request per "
|
||||||
|
+ "connection) • 0 = persistent • N = rotate every N requests"));
|
||||||
settings.addView(advancedCard, cardParams());
|
settings.addView(advancedCard, cardParams());
|
||||||
|
|
||||||
// ------------------------------------------------------------- buttons
|
// ------------------------------------------------------------- buttons
|
||||||
@@ -284,6 +312,7 @@ public class MainActivity extends Activity {
|
|||||||
+ " on errors, recovers to " + max + ".");
|
+ " on errors, recovers to " + max + ".");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private Integer readInt(EditText field) {
|
private Integer readInt(EditText field) {
|
||||||
if (field == null) return null;
|
if (field == null) return null;
|
||||||
String raw = field.getText().toString().trim();
|
String raw = field.getText().toString().trim();
|
||||||
@@ -364,6 +393,47 @@ public class MainActivity extends Activity {
|
|||||||
return edit;
|
return edit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Button segmentButton(String label) {
|
||||||
|
Button b = new Button(this);
|
||||||
|
b.setText(label);
|
||||||
|
b.setTextSize(15);
|
||||||
|
b.setTypeface(Typeface.DEFAULT, Typeface.BOLD);
|
||||||
|
b.setAllCaps(false);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addSegment(LinearLayout parent, Button b) {
|
||||||
|
LinearLayout.LayoutParams p = new LinearLayout.LayoutParams(0, dp(46), 1f);
|
||||||
|
if (parent.getChildCount() > 0) p.setMarginStart(dp(8));
|
||||||
|
parent.addView(b, p);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Selects the wire format and repaints the segmented control. */
|
||||||
|
private void setWireMode(String mode) {
|
||||||
|
wireMode = mode;
|
||||||
|
paintSegment(wireAuto, "auto".equals(mode));
|
||||||
|
paintSegment(wireB, "b".equals(mode));
|
||||||
|
paintSegment(wireX, "x".equals(mode));
|
||||||
|
if (wireHint == null) return;
|
||||||
|
if ("auto".equals(mode)) {
|
||||||
|
wireHint.setTextColor(ACCENT);
|
||||||
|
wireHint.setText("Auto: tries B, then X, keeping the one that connects.");
|
||||||
|
} else if ("b".equals(mode)) {
|
||||||
|
wireHint.setTextColor(OK);
|
||||||
|
wireHint.setText("Manual: B.");
|
||||||
|
} else {
|
||||||
|
wireHint.setTextColor(OK);
|
||||||
|
wireHint.setText("Manual: X.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void paintSegment(Button b, boolean selected) {
|
||||||
|
if (b == null) return;
|
||||||
|
b.setTextColor(selected ? Color.WHITE : Color.rgb(132, 142, 155));
|
||||||
|
b.setBackground(roundRect(selected ? ACCENT : Color.rgb(32, 38, 47), 12,
|
||||||
|
selected ? ACCENT : BORDER, 1));
|
||||||
|
}
|
||||||
|
|
||||||
private LinearLayout row() {
|
private LinearLayout row() {
|
||||||
LinearLayout row = new LinearLayout(this);
|
LinearLayout row = new LinearLayout(this);
|
||||||
row.setOrientation(LinearLayout.HORIZONTAL);
|
row.setOrientation(LinearLayout.HORIZONTAL);
|
||||||
@@ -446,11 +516,12 @@ public class MainActivity extends Activity {
|
|||||||
i.putExtra(DragonService.EXTRA_SERVER, p.getString("server", ""));
|
i.putExtra(DragonService.EXTRA_SERVER, p.getString("server", ""));
|
||||||
i.putExtra(DragonService.EXTRA_PORT, p.getInt("port", 53));
|
i.putExtra(DragonService.EXTRA_PORT, p.getInt("port", 53));
|
||||||
i.putExtra(DragonService.EXTRA_TOKEN, p.getString("token", ""));
|
i.putExtra(DragonService.EXTRA_TOKEN, p.getString("token", ""));
|
||||||
|
i.putExtra(DragonService.EXTRA_WIRE, p.getString("wire", "auto"));
|
||||||
i.putExtra(DragonService.EXTRA_CHUNK_MAX, p.getInt("max", 1048576));
|
i.putExtra(DragonService.EXTRA_CHUNK_MAX, p.getInt("max", 1048576));
|
||||||
i.putExtra(DragonService.EXTRA_CHUNK_MIN, p.getInt("min", 32));
|
i.putExtra(DragonService.EXTRA_CHUNK_MIN, p.getInt("min", 32));
|
||||||
i.putExtra(DragonService.EXTRA_BATCH_MAX, p.getInt("batchMax", 1));
|
i.putExtra(DragonService.EXTRA_BATCH_MAX, p.getInt("batchMax", 1));
|
||||||
i.putExtra(DragonService.EXTRA_BATCH_MIN, p.getInt("batchMin", 1));
|
i.putExtra(DragonService.EXTRA_BATCH_MIN, p.getInt("batchMin", 1));
|
||||||
i.putExtra(DragonService.EXTRA_RECONNECT, p.getInt("reconnect", 0));
|
i.putExtra(DragonService.EXTRA_RECONNECT, p.getInt("reconnect", 1));
|
||||||
i.putExtra(DragonService.EXTRA_TIMEOUT, p.getInt("timeout", 2));
|
i.putExtra(DragonService.EXTRA_TIMEOUT, p.getInt("timeout", 2));
|
||||||
if (Build.VERSION.SDK_INT >= 26) startForegroundService(i); else startService(i);
|
if (Build.VERSION.SDK_INT >= 26) startForegroundService(i); else startService(i);
|
||||||
}
|
}
|
||||||
@@ -471,6 +542,7 @@ public class MainActivity extends Activity {
|
|||||||
.putString("server", h)
|
.putString("server", h)
|
||||||
.putInt("port", p)
|
.putInt("port", p)
|
||||||
.putString("token", token.getText().toString())
|
.putString("token", token.getText().toString())
|
||||||
|
.putString("wire", wireMode)
|
||||||
.putInt("max", max)
|
.putInt("max", max)
|
||||||
.putInt("min", min)
|
.putInt("min", min)
|
||||||
.putInt("batchMax", bMax)
|
.putInt("batchMax", bMax)
|
||||||
@@ -493,11 +565,12 @@ public class MainActivity extends Activity {
|
|||||||
server.setText(p.getString("server", ""));
|
server.setText(p.getString("server", ""));
|
||||||
port.setText(Integer.toString(p.getInt("port", 53)));
|
port.setText(Integer.toString(p.getInt("port", 53)));
|
||||||
token.setText(p.getString("token", ""));
|
token.setText(p.getString("token", ""));
|
||||||
|
setWireMode(p.getString("wire", "auto"));
|
||||||
chunkMax.setText(Integer.toString(p.getInt("max", 1048576)));
|
chunkMax.setText(Integer.toString(p.getInt("max", 1048576)));
|
||||||
chunkMin.setText(Integer.toString(p.getInt("min", 32)));
|
chunkMin.setText(Integer.toString(p.getInt("min", 32)));
|
||||||
batchMax.setText(Integer.toString(p.getInt("batchMax", 1)));
|
batchMax.setText(Integer.toString(p.getInt("batchMax", 1)));
|
||||||
batchMin.setText(Integer.toString(p.getInt("batchMin", 1)));
|
batchMin.setText(Integer.toString(p.getInt("batchMin", 1)));
|
||||||
reconnect.setText(Integer.toString(p.getInt("reconnect", 0)));
|
reconnect.setText(Integer.toString(p.getInt("reconnect", 1)));
|
||||||
timeout.setText(Integer.toString(p.getInt("timeout", 2)));
|
timeout.setText(Integer.toString(p.getInt("timeout", 2)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -571,41 +571,40 @@ func openChunkTunnel(serverAddr, token, targetHost string, targetPort int, opts
|
|||||||
// 0 = persistent (CLI explicit)
|
// 0 = persistent (CLI explicit)
|
||||||
// 1 = auto: persistent when the path probe succeeds, otherwise one request/connection
|
// 1 = auto: persistent when the path probe succeeds, otherwise one request/connection
|
||||||
// N>=2 = force connection rotation after N logical requests
|
// N>=2 = force connection rotation after N logical requests
|
||||||
if reconnect == 1 {
|
// Resolved silently: this runs once per proxied flow, so it must never log.
|
||||||
if profile.persistent {
|
if reconnect == 1 && profile.persistent {
|
||||||
reconnect = 0
|
reconnect = 0
|
||||||
fmt.Printf("path probe: reconnect mode auto -> persistent\n")
|
|
||||||
} else {
|
|
||||||
fmt.Printf("path probe: reconnect mode auto -> every request\n")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sid, err := randomSessionID()
|
sid, err := randomSessionID()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
control := newRequestLane(serverAddr, opts.tcpBuffer, reconnect, opts.txnTimeout)
|
// OPEN rides the upload lane instead of a throwaway connection. A dedicated
|
||||||
|
// control connection cost one extra dial per proxied flow, which shows up on
|
||||||
|
// the server as connection churn on top of the steady-state count.
|
||||||
|
uploadLane := newRequestLane(serverAddr, opts.tcpBuffer, reconnect, opts.txnTimeout)
|
||||||
payload, err := encodeOpen(token, targetHost, targetPort)
|
payload, err := encodeOpen(token, targetHost, targetPort)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
control.Close()
|
uploadLane.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
status, body, err := control.single(wire.ModeOpen, sid, 0, payload)
|
status, body, err := uploadLane.single(wire.ModeOpen, sid, 0, payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
control.Close()
|
uploadLane.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if status == wire.StatusError {
|
if status == wire.StatusError {
|
||||||
control.Close()
|
uploadLane.Close()
|
||||||
return nil, fmt.Errorf("%s", string(body))
|
return nil, fmt.Errorf("%s", string(body))
|
||||||
}
|
}
|
||||||
if status != wire.StatusOK || len(body) != 4 {
|
if status != wire.StatusOK || len(body) != 4 {
|
||||||
control.Close()
|
uploadLane.Close()
|
||||||
return nil, fmt.Errorf("bad OPEN response")
|
return nil, fmt.Errorf("bad OPEN response")
|
||||||
}
|
}
|
||||||
serverMax := int(binary.BigEndian.Uint32(body))
|
serverMax := int(binary.BigEndian.Uint32(body))
|
||||||
control.Close()
|
|
||||||
if serverMax < opts.minSize {
|
if serverMax < opts.minSize {
|
||||||
|
uploadLane.Close()
|
||||||
return nil, fmt.Errorf("server maximum chunk %d is below client minimum %d", serverMax, opts.minSize)
|
return nil, fmt.Errorf("server maximum chunk %d is below client minimum %d", serverMax, opts.minSize)
|
||||||
}
|
}
|
||||||
if opts.maxSize > serverMax {
|
if opts.maxSize > serverMax {
|
||||||
@@ -624,13 +623,12 @@ func openChunkTunnel(serverAddr, token, targetHost string, targetPort int, opts
|
|||||||
sid: sid,
|
sid: sid,
|
||||||
opts: opts,
|
opts: opts,
|
||||||
serverMax: serverMax,
|
serverMax: serverMax,
|
||||||
uploadLane: newRequestLane(serverAddr, opts.tcpBuffer, reconnect, opts.txnTimeout),
|
uploadLane: uploadLane,
|
||||||
downloadLane: newRequestLane(serverAddr, opts.tcpBuffer, reconnect, opts.txnTimeout),
|
downloadLane: newRequestLane(serverAddr, opts.tcpBuffer, reconnect, opts.txnTimeout),
|
||||||
// Start at the user-configured ceiling. On transport failures the
|
// Start at the configured ceiling. On transport failure the batch is
|
||||||
// pipeline is halved; successful data responses grow it back by one,
|
// halved but never below minPipeline; successful data grows it back by
|
||||||
// always staying inside minPipeline..maxPipeline. When the two bounds
|
// one. When min == max the depth is pinned and never adapts, which is
|
||||||
// are equal the depth is pinned and never adapts, which is what paths
|
// what paths that only work at one specific batch size need.
|
||||||
// that only work at one specific batch size need.
|
|
||||||
pipeline: opts.maxPipeline,
|
pipeline: opts.maxPipeline,
|
||||||
minPipeline: opts.minPipeline,
|
minPipeline: opts.minPipeline,
|
||||||
maxPipeline: opts.maxPipeline,
|
maxPipeline: opts.maxPipeline,
|
||||||
@@ -640,13 +638,13 @@ func openChunkTunnel(serverAddr, token, targetHost string, targetPort int, opts
|
|||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// pinnedBatch reports whether the batch depth is fixed. A pinned depth never
|
func (c *chunkConn) fillReadBuffer() error {
|
||||||
// grows or shrinks: some paths only deliver correctly at one specific number of
|
if c.eof {
|
||||||
// records per request, so the adaptive controller must stay out of the way.
|
return io.EOF
|
||||||
func (c *chunkConn) pinnedBatch() bool { return c.minPipeline >= c.maxPipeline }
|
}
|
||||||
|
minFailures := 0
|
||||||
// batchCount is how many records the next download request will ask for.
|
for len(c.readBuf) == 0 && !c.eof {
|
||||||
func (c *chunkConn) batchCount(chunk int) int {
|
chunk := c.downSizer.Current()
|
||||||
count := c.pipeline
|
count := c.pipeline
|
||||||
if count < c.minPipeline {
|
if count < c.minPipeline {
|
||||||
count = c.minPipeline
|
count = c.minPipeline
|
||||||
@@ -659,42 +657,6 @@ func (c *chunkConn) batchCount(chunk int) int {
|
|||||||
if maxCount := (1024 * 1024) / maxInt(chunk, 1); maxCount < count {
|
if maxCount := (1024 * 1024) / maxInt(chunk, 1); maxCount < count {
|
||||||
count = maxInt(maxCount, c.minPipeline)
|
count = maxInt(maxCount, c.minPipeline)
|
||||||
}
|
}
|
||||||
return count
|
|
||||||
}
|
|
||||||
|
|
||||||
// growPipeline widens the batch by one after a successful data response.
|
|
||||||
func (c *chunkConn) growPipeline() {
|
|
||||||
if c.pinnedBatch() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if c.pipeline < c.maxPipeline {
|
|
||||||
c.pipeline++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// shrinkPipeline halves the batch after a transport failure. It reports the old
|
|
||||||
// and new depth, and whether anything actually changed; when it returns false
|
|
||||||
// the caller should shrink the record size instead.
|
|
||||||
func (c *chunkConn) shrinkPipeline() (int, int, bool) {
|
|
||||||
if c.pinnedBatch() || c.pipeline <= c.minPipeline {
|
|
||||||
return c.pipeline, c.pipeline, false
|
|
||||||
}
|
|
||||||
old := c.pipeline
|
|
||||||
c.pipeline /= 2
|
|
||||||
if c.pipeline < c.minPipeline {
|
|
||||||
c.pipeline = c.minPipeline
|
|
||||||
}
|
|
||||||
return old, c.pipeline, old != c.pipeline
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *chunkConn) fillReadBuffer() error {
|
|
||||||
if c.eof {
|
|
||||||
return io.EOF
|
|
||||||
}
|
|
||||||
minFailures := 0
|
|
||||||
for len(c.readBuf) == 0 && !c.eof {
|
|
||||||
chunk := c.downSizer.Current()
|
|
||||||
count := c.batchCount(chunk)
|
|
||||||
|
|
||||||
data, status, err := c.downloadLane.download(c.sid, c.downloadOffset, c.consumedOffset, chunk, count)
|
data, status, err := c.downloadLane.download(c.sid, c.downloadOffset, c.consumedOffset, chunk, count)
|
||||||
for _, part := range data {
|
for _, part := range data {
|
||||||
@@ -703,16 +665,20 @@ func (c *chunkConn) fillReadBuffer() error {
|
|||||||
}
|
}
|
||||||
if len(data) > 0 {
|
if len(data) > 0 {
|
||||||
c.downSizer.Success(chunk)
|
c.downSizer.Success(chunk)
|
||||||
c.growPipeline()
|
if c.pipeline < c.maxPipeline {
|
||||||
|
c.pipeline++
|
||||||
|
}
|
||||||
minFailures = 0
|
minFailures = 0
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Shrink the batch first, then the record size. When the batch is
|
if c.pipeline > c.minPipeline {
|
||||||
// pinned (min == max) the depth is left alone entirely and only the
|
old := c.pipeline
|
||||||
// record size adapts.
|
c.pipeline /= 2
|
||||||
if old, next, shrank := c.shrinkPipeline(); shrank {
|
if c.pipeline < c.minPipeline {
|
||||||
if c.opts.adaptLog {
|
c.pipeline = c.minPipeline
|
||||||
fmt.Printf("adaptive download batch: %d -> %d after transport failure\n", old, next)
|
}
|
||||||
|
if c.opts.adaptLog && old != c.pipeline {
|
||||||
|
fmt.Printf("adaptive download pipeline: %d -> %d after transport failure\n", old, c.pipeline)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
old, next := c.downSizer.Failure(chunk)
|
old, next := c.downSizer.Failure(chunk)
|
||||||
@@ -808,9 +774,9 @@ func (c *chunkConn) Write(p []byte) (int, error) {
|
|||||||
|
|
||||||
func (c *chunkConn) Close() error {
|
func (c *chunkConn) Close() error {
|
||||||
c.closeOnce.Do(func() {
|
c.closeOnce.Do(func() {
|
||||||
lane := newRequestLane(c.uploadLane.serverAddr, c.opts.tcpBuffer, 1, c.opts.txnTimeout)
|
// Reuse the upload lane rather than dialling a connection just to say
|
||||||
_, _, _ = lane.single(wire.ModeClose, c.sid, 0, nil)
|
// goodbye; that was a second wasted dial per flow.
|
||||||
lane.Close()
|
_, _, _ = c.uploadLane.single(wire.ModeClose, c.sid, 0, nil)
|
||||||
c.uploadLane.Close()
|
c.uploadLane.Close()
|
||||||
c.downloadLane.Close()
|
c.downloadLane.Close()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -23,76 +23,6 @@ func TestAdaptiveSizerRecoversFromMinimum(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTestConn(min, max int) *chunkConn {
|
|
||||||
return &chunkConn{pipeline: max, minPipeline: min, maxPipeline: max}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPinnedBatchNeverAdapts(t *testing.T) {
|
|
||||||
c := newTestConn(5, 5)
|
|
||||||
if got := c.batchCount(1400); got != 5 {
|
|
||||||
t.Fatalf("pinned batch should request 5 records, got %d", got)
|
|
||||||
}
|
|
||||||
for i := 0; i < 10; i++ {
|
|
||||||
if _, _, shrank := c.shrinkPipeline(); shrank {
|
|
||||||
t.Fatal("pinned batch shrank on transport failure")
|
|
||||||
}
|
|
||||||
c.growPipeline()
|
|
||||||
}
|
|
||||||
if c.pipeline != 5 {
|
|
||||||
t.Fatalf("pinned batch drifted to %d", c.pipeline)
|
|
||||||
}
|
|
||||||
if got := c.batchCount(1400); got != 5 {
|
|
||||||
t.Fatalf("pinned batch should still request 5 records, got %d", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPinnedBatchSurvivesOneMiBCap(t *testing.T) {
|
|
||||||
// 8 x 1 MiB records exceed the ~1 MiB useful-data cap. A pinned depth must
|
|
||||||
// win anyway, otherwise a path that needs exactly 8 records is broken by
|
|
||||||
// an unrelated size heuristic.
|
|
||||||
c := newTestConn(8, 8)
|
|
||||||
if got := c.batchCount(1024 * 1024); got != 8 {
|
|
||||||
t.Fatalf("pinned batch should ignore the 1 MiB cap, got %d", got)
|
|
||||||
}
|
|
||||||
// An unpinned batch is still capped.
|
|
||||||
c = newTestConn(1, 8)
|
|
||||||
if got := c.batchCount(1024 * 1024); got != 1 {
|
|
||||||
t.Fatalf("unpinned batch should be capped to 1, got %d", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAdaptiveBatchStopsAtFloor(t *testing.T) {
|
|
||||||
c := newTestConn(4, 32)
|
|
||||||
seen := map[int]bool{}
|
|
||||||
for i := 0; i < 12; i++ {
|
|
||||||
_, next, _ := c.shrinkPipeline()
|
|
||||||
seen[next] = true
|
|
||||||
}
|
|
||||||
if c.pipeline != 4 {
|
|
||||||
t.Fatalf("batch fell to %d, want the floor 4", c.pipeline)
|
|
||||||
}
|
|
||||||
if !seen[16] || !seen[8] {
|
|
||||||
t.Fatalf("expected halving through 16 and 8, saw %v", seen)
|
|
||||||
}
|
|
||||||
for i := 0; i < 100; i++ {
|
|
||||||
c.growPipeline()
|
|
||||||
}
|
|
||||||
if c.pipeline != 32 {
|
|
||||||
t.Fatalf("batch grew to %d, want the ceiling 32", c.pipeline)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSingleBatchIsFixed(t *testing.T) {
|
|
||||||
c := newTestConn(1, 1)
|
|
||||||
if !c.pinnedBatch() {
|
|
||||||
t.Fatal("a 1..1 batch must be treated as pinned")
|
|
||||||
}
|
|
||||||
c.growPipeline()
|
|
||||||
if c.pipeline != 1 {
|
|
||||||
t.Fatalf("batch of 1 grew to %d", c.pipeline)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReconnectZeroMeansPersistent(t *testing.T) {
|
func TestReconnectZeroMeansPersistent(t *testing.T) {
|
||||||
lane := newRequestLane("127.0.0.1:1", 0, 0, 0)
|
lane := newRequestLane("127.0.0.1:1", 0, 0, 0)
|
||||||
if lane.reconnectEvery != 0 {
|
if lane.reconnectEvery != 0 {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"dragontcp/internal/protocol"
|
"dragontcp/internal/protocol"
|
||||||
|
"dragontcp/internal/xorchunk"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxHeader = 128 * 1024
|
const maxHeader = 128 * 1024
|
||||||
@@ -251,7 +252,7 @@ func writeHTTPError(conn net.Conn, code int, reason, detail string) {
|
|||||||
_, _ = conn.Write(body)
|
_, _ = conn.Write(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleLocal(conn net.Conn, serverAddr, token, transport string, tcpBuffer int, chunkOpts chunkClientOptions, slots chan struct{}) {
|
func handleLocal(conn net.Conn, serverAddr, token, transport string, tcpBuffer int, wires *wireSelector, slots chan struct{}) {
|
||||||
defer func() {
|
defer func() {
|
||||||
<-slots
|
<-slots
|
||||||
_ = conn.Close()
|
_ = conn.Close()
|
||||||
@@ -285,7 +286,7 @@ func handleLocal(conn net.Conn, serverAddr, token, transport string, tcpBuffer i
|
|||||||
|
|
||||||
var remote net.Conn
|
var remote net.Conn
|
||||||
if transport == "chunk" {
|
if transport == "chunk" {
|
||||||
remote, err = openChunkTunnel(serverAddr, token, host, port, chunkOpts)
|
remote, err = wires.dial(host, port)
|
||||||
} else {
|
} else {
|
||||||
remote, err = openDragonTCPTunnel(serverAddr, token, host, port, transport, tcpBuffer)
|
remote, err = openDragonTCPTunnel(serverAddr, token, host, port, transport, tcpBuffer)
|
||||||
}
|
}
|
||||||
@@ -327,7 +328,7 @@ func handleLocal(conn net.Conn, serverAddr, token, transport string, tcpBuffer i
|
|||||||
|
|
||||||
var remote net.Conn
|
var remote net.Conn
|
||||||
if transport == "chunk" {
|
if transport == "chunk" {
|
||||||
remote, err = openChunkTunnel(serverAddr, token, host, port, chunkOpts)
|
remote, err = wires.dial(host, port)
|
||||||
} else {
|
} else {
|
||||||
remote, err = openDragonTCPTunnel(serverAddr, token, host, port, transport, tcpBuffer)
|
remote, err = openDragonTCPTunnel(serverAddr, token, host, port, transport, tcpBuffer)
|
||||||
}
|
}
|
||||||
@@ -375,10 +376,11 @@ func main() {
|
|||||||
chunkSizeLegacy = flag.Int("chunk-size", 0, "legacy fixed chunk size; nonzero disables adaptation")
|
chunkSizeLegacy = flag.Int("chunk-size", 0, "legacy fixed chunk size; nonzero disables adaptation")
|
||||||
chunkPollers = flag.Int("chunk-pollers", 1, "reserved compatibility setting; binary transport uses one download worker")
|
chunkPollers = flag.Int("chunk-pollers", 1, "reserved compatibility setting; binary transport uses one download worker")
|
||||||
chunkConcurrency = flag.Int("chunk-concurrency", 1, "maximum download records per request (1-256)")
|
chunkConcurrency = flag.Int("chunk-concurrency", 1, "maximum download records per request (1-256)")
|
||||||
chunkConcurrencyMin = flag.Int("chunk-concurrency-min", 1, "minimum download records per request (1-256); equal to --chunk-concurrency pins the depth and disables batch adaptation")
|
chunkConcurrencyMin = flag.Int("chunk-concurrency-min", 1, "minimum download records per request (1-256); equal to --chunk-concurrency pins the depth")
|
||||||
chunkReconnect = flag.Int("chunk-reconnect-every", 0, "force reconnect after N logical requests; 0 = persistent/automatic")
|
chunkReconnect = flag.Int("chunk-reconnect-every", 0, "force reconnect after N logical requests; 0 = persistent/automatic")
|
||||||
chunkPollDelay = flag.Duration("chunk-poll-delay", 2*time.Millisecond, "delay after an empty chunk poll")
|
chunkPollDelay = flag.Duration("chunk-poll-delay", 2*time.Millisecond, "delay after an empty chunk poll")
|
||||||
chunkTimeout = flag.Duration("chunk-timeout", 2*time.Second, "per-record transaction timeout before adaptive shrink")
|
chunkTimeout = flag.Duration("chunk-timeout", 2*time.Second, "per-record transaction timeout before adaptive shrink")
|
||||||
|
wireMode = flag.String("wire", "auto", "wire mode: b, x, or auto (probe and pick)")
|
||||||
)
|
)
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
@@ -426,6 +428,17 @@ func main() {
|
|||||||
fmt.Fprintln(os.Stderr, "--chunk-concurrency-min must not exceed --chunk-concurrency")
|
fmt.Fprintln(os.Stderr, "--chunk-concurrency-min must not exceed --chunk-concurrency")
|
||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
}
|
}
|
||||||
|
*wireMode = strings.ToLower(strings.TrimSpace(*wireMode))
|
||||||
|
switch *wireMode {
|
||||||
|
case WireBinary, WireXOR, WireAuto:
|
||||||
|
case "binary":
|
||||||
|
*wireMode = WireBinary
|
||||||
|
case "xor":
|
||||||
|
*wireMode = WireXOR
|
||||||
|
default:
|
||||||
|
fmt.Fprintln(os.Stderr, "--wire must be b, x or auto")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
if *chunkReconnect < 0 {
|
if *chunkReconnect < 0 {
|
||||||
fmt.Fprintln(os.Stderr, "--chunk-reconnect-every must be 0 or greater")
|
fmt.Fprintln(os.Stderr, "--chunk-reconnect-every must be 0 or greater")
|
||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
@@ -438,14 +451,19 @@ func main() {
|
|||||||
adaptSuccesses: *chunkSuccesses,
|
adaptSuccesses: *chunkSuccesses,
|
||||||
adaptLog: *chunkAdaptLog,
|
adaptLog: *chunkAdaptLog,
|
||||||
pollers: *chunkPollers,
|
pollers: *chunkPollers,
|
||||||
|
minPipeline: *chunkConcurrencyMin,
|
||||||
|
maxPipeline: *chunkConcurrency,
|
||||||
reconnectEvery: *chunkReconnect,
|
reconnectEvery: *chunkReconnect,
|
||||||
pollDelay: *chunkPollDelay,
|
pollDelay: *chunkPollDelay,
|
||||||
txnTimeout: *chunkTimeout,
|
txnTimeout: *chunkTimeout,
|
||||||
tcpBuffer: *tcpBuffer,
|
tcpBuffer: *tcpBuffer,
|
||||||
minPipeline: *chunkConcurrencyMin,
|
|
||||||
maxPipeline: *chunkConcurrency,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
xorOpts := xorchunk.NewOptions(
|
||||||
|
*chunkStart, *chunkMin, *chunkMax, *chunkAdaptive, *chunkSuccesses, *chunkAdaptLog,
|
||||||
|
*chunkPollers, *chunkReconnect, *chunkPollDelay, *chunkTimeout, *tcpBuffer,
|
||||||
|
)
|
||||||
|
|
||||||
listenAddr := net.JoinHostPort(*listenHost, strconv.Itoa(*listenPort))
|
listenAddr := net.JoinHostPort(*listenHost, strconv.Itoa(*listenPort))
|
||||||
serverAddr := net.JoinHostPort(*serverHost, strconv.Itoa(*serverPort))
|
serverAddr := net.JoinHostPort(*serverHost, strconv.Itoa(*serverPort))
|
||||||
|
|
||||||
@@ -480,6 +498,16 @@ func main() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
wires := newWireSelector(*wireMode, serverAddr, *token, chunkOpts, xorOpts)
|
||||||
|
if *wireMode == WireAuto {
|
||||||
|
fmt.Printf("wire=auto probing %s\n", probeHost)
|
||||||
|
// Resolve in the background so startup is not blocked; a connection that
|
||||||
|
// arrives first simply waits for the same result.
|
||||||
|
go wires.mode()
|
||||||
|
} else {
|
||||||
|
fmt.Printf("wire=%s (manual)\n", *wireMode)
|
||||||
|
}
|
||||||
|
|
||||||
slots := make(chan struct{}, *maxConnections)
|
slots := make(chan struct{}, *maxConnections)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
@@ -491,7 +519,7 @@ func main() {
|
|||||||
|
|
||||||
select {
|
select {
|
||||||
case slots <- struct{}{}:
|
case slots <- struct{}{}:
|
||||||
go handleLocal(conn, serverAddr, *token, *transport, *tcpBuffer, chunkOpts, slots)
|
go handleLocal(conn, serverAddr, *token, *transport, *tcpBuffer, wires, slots)
|
||||||
default:
|
default:
|
||||||
writeHTTPError(
|
writeHTTPError(
|
||||||
conn,
|
conn,
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"dragontcp/internal/xorchunk"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DragonTCP speaks two wires that are not interchangeable:
|
||||||
|
//
|
||||||
|
// b — compact binary records (29/5-byte headers, SHA-256 keystream mask)
|
||||||
|
// x — legacy UP/OK framing with XOR 0xAD over ASCII chunk commands
|
||||||
|
//
|
||||||
|
// Networks differ in which they pass, so the client can be pinned to either or
|
||||||
|
// left on auto, which decides by actually fetching a URL through each wire and
|
||||||
|
// keeping the first that answers.
|
||||||
|
const (
|
||||||
|
WireBinary = "b"
|
||||||
|
WireXOR = "x"
|
||||||
|
WireAuto = "auto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// probeTarget is fetched through a candidate wire to decide whether it works.
|
||||||
|
// A plain HTTP host is used deliberately: it exercises OPEN, upload and
|
||||||
|
// download in one go, and a valid status line proves bytes survived intact.
|
||||||
|
const (
|
||||||
|
probeHost = "ip.dr2.site"
|
||||||
|
probePort = 80
|
||||||
|
probeTimeout = 8 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
type wireSelector struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
configured string // b, x or auto
|
||||||
|
resolved string // b or x once decided
|
||||||
|
serverAddr string
|
||||||
|
token string
|
||||||
|
binOpts chunkClientOptions
|
||||||
|
xorOpts xorchunk.Options
|
||||||
|
}
|
||||||
|
|
||||||
|
func newWireSelector(configured, serverAddr, token string, binOpts chunkClientOptions, xorOpts xorchunk.Options) *wireSelector {
|
||||||
|
s := &wireSelector{
|
||||||
|
configured: configured,
|
||||||
|
serverAddr: serverAddr,
|
||||||
|
token: token,
|
||||||
|
binOpts: binOpts,
|
||||||
|
xorOpts: xorOpts,
|
||||||
|
}
|
||||||
|
if configured != WireAuto {
|
||||||
|
s.resolved = configured
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// dial opens a tunnel over the active wire, resolving the wire first if needed.
|
||||||
|
func (s *wireSelector) dial(host string, port int) (net.Conn, error) {
|
||||||
|
mode := s.mode()
|
||||||
|
if mode == WireXOR {
|
||||||
|
return xorchunk.Open(s.serverAddr, s.token, host, port, s.xorOpts)
|
||||||
|
}
|
||||||
|
return openChunkTunnel(s.serverAddr, s.token, host, port, s.binOpts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mode returns the wire to use, running detection once if configured as auto.
|
||||||
|
// Detection failure is not cached, so a client that starts before the network
|
||||||
|
// is usable retries on the next connection instead of latching a bad guess.
|
||||||
|
func (s *wireSelector) mode() string {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.resolved != "" {
|
||||||
|
return s.resolved
|
||||||
|
}
|
||||||
|
if picked, ok := s.detectLocked(); ok {
|
||||||
|
s.resolved = picked
|
||||||
|
return picked
|
||||||
|
}
|
||||||
|
// Undecided: use the binary wire for this attempt without caching it.
|
||||||
|
return WireBinary
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *wireSelector) detectLocked() (string, bool) {
|
||||||
|
for _, candidate := range []string{WireBinary, WireXOR} {
|
||||||
|
if s.probe(candidate) {
|
||||||
|
fmt.Printf("wire probe: %s selected via %s\n", candidate, probeHost)
|
||||||
|
return candidate, true
|
||||||
|
}
|
||||||
|
fmt.Printf("wire probe: %s failed\n", candidate)
|
||||||
|
}
|
||||||
|
fmt.Printf("wire probe: neither wire reached %s; retrying later\n", probeHost)
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// probe fetches probeHost through one wire and reports whether a well-formed
|
||||||
|
// HTTP status line came back.
|
||||||
|
func (s *wireSelector) probe(mode string) bool {
|
||||||
|
type result struct{ ok bool }
|
||||||
|
done := make(chan result, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
var (
|
||||||
|
conn net.Conn
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
if mode == WireXOR {
|
||||||
|
conn, err = xorchunk.Open(s.serverAddr, s.token, probeHost, probePort, s.xorOpts)
|
||||||
|
} else {
|
||||||
|
conn, err = openChunkTunnel(s.serverAddr, s.token, probeHost, probePort, s.binOpts)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
done <- result{false}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
request := "GET / HTTP/1.1\r\nHost: " + probeHost + "\r\nUser-Agent: dragontcp\r\nConnection: close\r\n\r\n"
|
||||||
|
if _, err := conn.Write([]byte(request)); err != nil {
|
||||||
|
done <- result{false}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
buf := make([]byte, 64)
|
||||||
|
n, err := conn.Read(buf)
|
||||||
|
if n <= 0 || (err != nil && n == 0) {
|
||||||
|
done <- result{false}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
done <- result{strings.HasPrefix(string(buf[:n]), "HTTP/")}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case r := <-done:
|
||||||
|
return r.ok
|
||||||
|
case <-time.After(probeTimeout):
|
||||||
|
// The tunnel goroutine is left to unwind on its own; the wire simply
|
||||||
|
// did not answer in time, which is all the caller needs to know.
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -160,8 +160,10 @@ func handle(
|
|||||||
tcpBuffer int,
|
tcpBuffer int,
|
||||||
slots chan struct{},
|
slots chan struct{},
|
||||||
manager *streamManager,
|
manager *streamManager,
|
||||||
|
xorManager *chunkManager,
|
||||||
chunkMax int,
|
chunkMax int,
|
||||||
bufferBytes int,
|
bufferBytes int,
|
||||||
|
chunkBuffered int,
|
||||||
chunkPollWait time.Duration,
|
chunkPollWait time.Duration,
|
||||||
debug *serverDebug,
|
debug *serverDebug,
|
||||||
) {
|
) {
|
||||||
@@ -174,6 +176,26 @@ func handle(
|
|||||||
protocol.TuneTCP(conn)
|
protocol.TuneTCP(conn)
|
||||||
protocol.TuneTCPBuffer(conn, tcpBuffer)
|
protocol.TuneTCPBuffer(conn, tcpBuffer)
|
||||||
|
|
||||||
|
// One listener serves both wires. The legacy XOR framing starts every
|
||||||
|
// request with the ASCII magic "UP"; the binary framing starts with a mode
|
||||||
|
// byte of 0-4, so the two are never ambiguous.
|
||||||
|
_ = conn.SetDeadline(time.Now().Add(30 * time.Second))
|
||||||
|
conn, isXOR, err := sniffWire(conn)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isXOR {
|
||||||
|
if debug != nil && debug.enabled {
|
||||||
|
debug.logf("WIRE peer=%v mode=xor", conn.RemoteAddr())
|
||||||
|
}
|
||||||
|
handleXOR(conn, token, allowPrivate, cache, tcpBuffer, xorManager,
|
||||||
|
chunkMax, chunkBuffered, chunkPollWait, debug)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if debug != nil && debug.enabled {
|
||||||
|
debug.logf("WIRE peer=%v mode=binary", conn.RemoteAddr())
|
||||||
|
}
|
||||||
|
|
||||||
for {
|
for {
|
||||||
_ = conn.SetDeadline(time.Now().Add(30 * time.Second))
|
_ = conn.SetDeadline(time.Now().Add(30 * time.Second))
|
||||||
req, err := wire.ReadRequest(conn)
|
req, err := wire.ReadRequest(conn)
|
||||||
@@ -249,6 +271,7 @@ func main() {
|
|||||||
bufferBytes = 64 * 1024 * 1024
|
bufferBytes = 64 * 1024 * 1024
|
||||||
}
|
}
|
||||||
manager := newStreamManager(*sessionTimeout, debug)
|
manager := newStreamManager(*sessionTimeout, debug)
|
||||||
|
xorManager := newChunkManager(*sessionTimeout, debug)
|
||||||
fmt.Printf("binary_transport=true chunk_max=%d buffer_bytes=%d poll_wait=%s\n", *chunkMax, bufferBytes, chunkPollWait.String())
|
fmt.Printf("binary_transport=true chunk_max=%d buffer_bytes=%d poll_wait=%s\n", *chunkMax, bufferBytes, chunkPollWait.String())
|
||||||
if debug.enabled {
|
if debug.enabled {
|
||||||
fmt.Printf("debug=true debug_chunks=%t stats_interval=%s\n", debug.chunks, debug.statsEvery)
|
fmt.Printf("debug=true debug_chunks=%t stats_interval=%s\n", debug.chunks, debug.statsEvery)
|
||||||
@@ -275,8 +298,10 @@ func main() {
|
|||||||
*tcpBuffer,
|
*tcpBuffer,
|
||||||
slots,
|
slots,
|
||||||
manager,
|
manager,
|
||||||
|
xorManager,
|
||||||
*chunkMax,
|
*chunkMax,
|
||||||
bufferBytes,
|
bufferBytes,
|
||||||
|
*chunkBuffered,
|
||||||
*chunkPollWait,
|
*chunkPollWait,
|
||||||
debug,
|
debug,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,632 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// This file is the LiteVPN v4 XOR chunk server, carried over verbatim. It
|
||||||
|
// handles the legacy UP/OK + XOR 0xAD wire (COPEN / CPUSH / CPULL / CCLOSE and
|
||||||
|
// the TUNNEL stream commands) so one server accepts both wire formats. The
|
||||||
|
// binary record handler lives in chunk.go; nothing here is shared with it
|
||||||
|
// except the target dialler, DNS cache, token check and debug counters.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"dragontcp/internal/protocol"
|
||||||
|
)
|
||||||
|
|
||||||
|
type chunkSession struct {
|
||||||
|
id string
|
||||||
|
target net.Conn
|
||||||
|
maxChunk int
|
||||||
|
maxChunks int
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
notify chan struct{}
|
||||||
|
chunks map[uint64][]byte
|
||||||
|
nextDown uint64
|
||||||
|
eof bool
|
||||||
|
closed bool
|
||||||
|
lastSeen time.Time
|
||||||
|
debug *serverDebug
|
||||||
|
|
||||||
|
upMu sync.Mutex
|
||||||
|
expectedUp uint64
|
||||||
|
lastUpSeq uint64
|
||||||
|
lastUpLen int
|
||||||
|
haveLastUp bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newChunkSession(id string, target net.Conn, maxChunk, maxChunks int, debug *serverDebug) *chunkSession {
|
||||||
|
s := &chunkSession{
|
||||||
|
id: id,
|
||||||
|
target: target,
|
||||||
|
maxChunk: maxChunk,
|
||||||
|
maxChunks: maxChunks,
|
||||||
|
notify: make(chan struct{}),
|
||||||
|
chunks: make(map[uint64][]byte, maxChunks),
|
||||||
|
lastSeen: time.Now(),
|
||||||
|
debug: debug,
|
||||||
|
}
|
||||||
|
go s.readTarget()
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *chunkSession) signalLocked() {
|
||||||
|
close(s.notify)
|
||||||
|
s.notify = make(chan struct{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *chunkSession) touchLocked() {
|
||||||
|
s.lastSeen = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *chunkSession) touch() {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.touchLocked()
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *chunkSession) readTarget() {
|
||||||
|
buf := make([]byte, s.maxChunk)
|
||||||
|
|
||||||
|
for {
|
||||||
|
n, err := s.target.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
data := append([]byte(nil), buf[:n]...)
|
||||||
|
if s.debug != nil && s.debug.enabled {
|
||||||
|
s.debug.bytesDown.Add(uint64(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.closed {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(s.chunks) < s.maxChunks {
|
||||||
|
seq := s.nextDown
|
||||||
|
s.nextDown++
|
||||||
|
s.chunks[seq] = data
|
||||||
|
s.touchLocked()
|
||||||
|
s.signalLocked()
|
||||||
|
s.mu.Unlock()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
ch := s.notify
|
||||||
|
s.mu.Unlock()
|
||||||
|
<-ch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if s.debug != nil && s.debug.enabled {
|
||||||
|
s.debug.logf("TARGET EOF session=%s err=%v", s.id, err)
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
if !s.closed {
|
||||||
|
s.eof = true
|
||||||
|
s.touchLocked()
|
||||||
|
s.signalLocked()
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// push is idempotent for the most recently accepted sequence. This matters
|
||||||
|
// when the server receives a record but the tiny ACK is lost: the client can
|
||||||
|
// retry the same sequence at a smaller adaptive size without duplicating bytes
|
||||||
|
// in the target stream. The ACK reports the length that was actually accepted.
|
||||||
|
func (s *chunkSession) push(seq uint64, data []byte) (int, error) {
|
||||||
|
s.upMu.Lock()
|
||||||
|
defer s.upMu.Unlock()
|
||||||
|
|
||||||
|
if len(data) == 0 || len(data) > s.maxChunk {
|
||||||
|
return 0, fmt.Errorf("upload record size %d is invalid", len(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.haveLastUp && seq == s.lastUpSeq {
|
||||||
|
s.touch()
|
||||||
|
return s.lastUpLen, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if seq < s.expectedUp {
|
||||||
|
return 0, fmt.Errorf("upload sequence %d is too old", seq)
|
||||||
|
}
|
||||||
|
if seq > s.expectedUp {
|
||||||
|
return 0, fmt.Errorf("unexpected upload sequence %d, expected %d", seq, s.expectedUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.target.Write(data); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.debug != nil && s.debug.enabled {
|
||||||
|
s.debug.bytesUp.Add(uint64(len(data)))
|
||||||
|
s.debug.pushRecords.Add(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.lastUpSeq = seq
|
||||||
|
s.lastUpLen = len(data)
|
||||||
|
s.haveLastUp = true
|
||||||
|
s.expectedUp++
|
||||||
|
s.touch()
|
||||||
|
return len(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pull returns at most limit bytes from the requested stored chunk, beginning
|
||||||
|
// at offset. The chunk sequence stays stable while the client retries smaller
|
||||||
|
// fragments, so a large queued chunk can always be recovered after an MTU-like
|
||||||
|
// failure without reopening the proxied destination connection.
|
||||||
|
func (s *chunkSession) pull(want uint64, ack int64, offset, limit int, wait time.Duration) (data []byte, total int, eof bool, final uint64, waitExpired bool, err error) {
|
||||||
|
if offset < 0 || limit <= 0 || limit > s.maxChunk {
|
||||||
|
return nil, 0, false, 0, false, fmt.Errorf("invalid pull offset/limit")
|
||||||
|
}
|
||||||
|
|
||||||
|
timer := time.NewTimer(wait)
|
||||||
|
defer timer.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.touchLocked()
|
||||||
|
|
||||||
|
if ack >= 0 {
|
||||||
|
removed := false
|
||||||
|
for seq := range s.chunks {
|
||||||
|
if seq <= uint64(ack) {
|
||||||
|
delete(s.chunks, seq)
|
||||||
|
removed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if removed {
|
||||||
|
s.signalLocked()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if chunk, ok := s.chunks[want]; ok {
|
||||||
|
if offset >= len(chunk) {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil, len(chunk), false, 0, false, fmt.Errorf("pull offset %d beyond chunk size %d", offset, len(chunk))
|
||||||
|
}
|
||||||
|
end := offset + limit
|
||||||
|
if end > len(chunk) {
|
||||||
|
end = len(chunk)
|
||||||
|
}
|
||||||
|
out := append([]byte(nil), chunk[offset:end]...)
|
||||||
|
total = len(chunk)
|
||||||
|
s.mu.Unlock()
|
||||||
|
return out, total, false, 0, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.eof && want >= s.nextDown {
|
||||||
|
final = s.nextDown
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil, 0, true, final, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.closed {
|
||||||
|
final = s.nextDown
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil, 0, true, final, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ch := s.notify
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ch:
|
||||||
|
continue
|
||||||
|
case <-timer.C:
|
||||||
|
return nil, 0, false, 0, true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *chunkSession) close() {
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.closed {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.closed = true
|
||||||
|
s.signalLocked()
|
||||||
|
s.mu.Unlock()
|
||||||
|
_ = s.target.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
type chunkManager struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
sessions map[string]*chunkSession
|
||||||
|
timeout time.Duration
|
||||||
|
debug *serverDebug
|
||||||
|
}
|
||||||
|
|
||||||
|
func newChunkManager(timeout time.Duration, debug *serverDebug) *chunkManager {
|
||||||
|
m := &chunkManager{
|
||||||
|
sessions: make(map[string]*chunkSession),
|
||||||
|
timeout: timeout,
|
||||||
|
debug: debug,
|
||||||
|
}
|
||||||
|
go m.cleanupLoop()
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *chunkManager) get(id string) *chunkSession {
|
||||||
|
m.mu.RLock()
|
||||||
|
s := m.sessions[id]
|
||||||
|
m.mu.RUnlock()
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *chunkManager) count() int {
|
||||||
|
m.mu.RLock()
|
||||||
|
n := len(m.sessions)
|
||||||
|
m.mu.RUnlock()
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *chunkManager) add(id string, s *chunkSession) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if _, exists := m.sessions[id]; exists {
|
||||||
|
return fmt.Errorf("session already exists")
|
||||||
|
}
|
||||||
|
m.sessions[id] = s
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *chunkManager) remove(id string) {
|
||||||
|
m.mu.Lock()
|
||||||
|
s := m.sessions[id]
|
||||||
|
delete(m.sessions, id)
|
||||||
|
m.mu.Unlock()
|
||||||
|
if s != nil {
|
||||||
|
s.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *chunkManager) cleanupLoop() {
|
||||||
|
ticker := time.NewTicker(30 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for range ticker.C {
|
||||||
|
cutoff := time.Now().Add(-m.timeout)
|
||||||
|
var stale []string
|
||||||
|
|
||||||
|
m.mu.RLock()
|
||||||
|
for id, s := range m.sessions {
|
||||||
|
s.mu.Lock()
|
||||||
|
last := s.lastSeen
|
||||||
|
closed := s.closed
|
||||||
|
s.mu.Unlock()
|
||||||
|
if closed || last.Before(cutoff) {
|
||||||
|
stale = append(stale, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
for _, id := range stale {
|
||||||
|
if m.debug != nil && m.debug.enabled {
|
||||||
|
m.debug.logf("SESSION timeout-close id=%s active_sessions=%d", id, m.count())
|
||||||
|
}
|
||||||
|
m.remove(id)
|
||||||
|
if m.debug != nil && m.debug.enabled {
|
||||||
|
m.debug.sessionsClosed.Add(1)
|
||||||
|
m.debug.activeSessions.Add(-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeWireToken(token string) string {
|
||||||
|
if token == "-" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
func isChunkCommand(payload []byte) bool {
|
||||||
|
return bytes.HasPrefix(payload, []byte("COPEN ")) ||
|
||||||
|
bytes.HasPrefix(payload, []byte("CPUSH ")) ||
|
||||||
|
bytes.HasPrefix(payload, []byte("CPULL ")) ||
|
||||||
|
bytes.HasPrefix(payload, []byte("CCLOSE "))
|
||||||
|
}
|
||||||
|
|
||||||
|
func processChunkCommand(
|
||||||
|
conn net.Conn,
|
||||||
|
requestID uint32,
|
||||||
|
payload []byte,
|
||||||
|
token string,
|
||||||
|
allowPrivate bool,
|
||||||
|
cache *dnsCache,
|
||||||
|
tcpBuffer int,
|
||||||
|
manager *chunkManager,
|
||||||
|
maxChunk int,
|
||||||
|
maxBufferedChunks int,
|
||||||
|
pollWait time.Duration,
|
||||||
|
debug *serverDebug,
|
||||||
|
) error {
|
||||||
|
if bytes.HasPrefix(payload, []byte("COPEN ")) {
|
||||||
|
parts := strings.Fields(string(payload))
|
||||||
|
if len(parts) != 5 {
|
||||||
|
return protocol.WriteResponseFrame(conn, requestID, []byte("ERR bad COPEN"))
|
||||||
|
}
|
||||||
|
if !tokenEqual(decodeWireToken(parts[1]), token) {
|
||||||
|
return protocol.WriteResponseFrame(conn, requestID, []byte("ERR authentication failed"))
|
||||||
|
}
|
||||||
|
sid := parts[2]
|
||||||
|
if len(sid) < 16 || len(sid) > 64 {
|
||||||
|
return protocol.WriteResponseFrame(conn, requestID, []byte("ERR invalid session id"))
|
||||||
|
}
|
||||||
|
host := parts[3]
|
||||||
|
port, err := strconv.Atoi(parts[4])
|
||||||
|
if err != nil || port < 1 || port > 65535 {
|
||||||
|
return protocol.WriteResponseFrame(conn, requestID, []byte("ERR invalid port"))
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
target, err := dialTarget(ctx, host, port, allowPrivate, cache, tcpBuffer)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
return protocol.WriteResponseFrame(conn, requestID, []byte("ERR "+err.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
session := newChunkSession(sid, target, maxChunk, maxBufferedChunks, debug)
|
||||||
|
if err := manager.add(sid, session); err != nil {
|
||||||
|
session.close()
|
||||||
|
if debug != nil && debug.enabled {
|
||||||
|
debug.errorf("COPEN session=%s target=%s:%d failed: %v", sid, host, port, err)
|
||||||
|
}
|
||||||
|
return protocol.WriteResponseFrame(conn, requestID, []byte("ERR "+err.Error()))
|
||||||
|
}
|
||||||
|
if debug != nil && debug.enabled {
|
||||||
|
debug.sessionsOpened.Add(1)
|
||||||
|
debug.activeSessions.Add(1)
|
||||||
|
debug.logf("SESSION OPEN id=%s peer=%v target=%s:%d max_chunk=%d active_sessions=%d", sid, conn.RemoteAddr(), host, port, maxChunk, manager.count())
|
||||||
|
debug.chunkf("COPEN id=%s target=%s:%d -> OPENED max=%d", sid, host, port, maxChunk)
|
||||||
|
}
|
||||||
|
return protocol.WriteResponseFrame(conn, requestID, []byte(fmt.Sprintf("OPENED %d", maxChunk)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if bytes.HasPrefix(payload, []byte("CPUSH ")) {
|
||||||
|
parts := bytes.SplitN(payload, []byte(" "), 5)
|
||||||
|
if len(parts) != 5 {
|
||||||
|
return protocol.WriteResponseFrame(conn, requestID, []byte("ERR bad CPUSH"))
|
||||||
|
}
|
||||||
|
if !tokenEqual(decodeWireToken(string(parts[1])), token) {
|
||||||
|
return protocol.WriteResponseFrame(conn, requestID, []byte("ERR authentication failed"))
|
||||||
|
}
|
||||||
|
sid := string(parts[2])
|
||||||
|
seq, err := strconv.ParseUint(string(parts[3]), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return protocol.WriteResponseFrame(conn, requestID, []byte("ERR invalid sequence"))
|
||||||
|
}
|
||||||
|
s := manager.get(sid)
|
||||||
|
if s == nil {
|
||||||
|
return protocol.WriteResponseFrame(conn, requestID, []byte("ERR unknown session"))
|
||||||
|
}
|
||||||
|
accepted, err := s.push(seq, parts[4])
|
||||||
|
if err != nil {
|
||||||
|
if debug != nil && debug.enabled {
|
||||||
|
debug.errorf("CPUSH id=%s seq=%d bytes=%d: %v", sid, seq, len(parts[4]), err)
|
||||||
|
}
|
||||||
|
return protocol.WriteResponseFrame(conn, requestID, []byte("ERR "+err.Error()))
|
||||||
|
}
|
||||||
|
if debug != nil {
|
||||||
|
debug.chunkf("CPUSH id=%s seq=%d bytes=%d -> ACK accepted=%d", sid, seq, len(parts[4]), accepted)
|
||||||
|
}
|
||||||
|
return protocol.WriteResponseFrame(conn, requestID, []byte(fmt.Sprintf("ACK %d %d", seq, accepted)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if bytes.HasPrefix(payload, []byte("CPULL ")) {
|
||||||
|
parts := strings.Fields(string(payload))
|
||||||
|
if len(parts) != 7 {
|
||||||
|
return protocol.WriteResponseFrame(conn, requestID, []byte("ERR bad CPULL"))
|
||||||
|
}
|
||||||
|
if !tokenEqual(decodeWireToken(parts[1]), token) {
|
||||||
|
return protocol.WriteResponseFrame(conn, requestID, []byte("ERR authentication failed"))
|
||||||
|
}
|
||||||
|
s := manager.get(parts[2])
|
||||||
|
if s == nil {
|
||||||
|
return protocol.WriteResponseFrame(conn, requestID, []byte("ERR unknown session"))
|
||||||
|
}
|
||||||
|
ack, err := strconv.ParseInt(parts[3], 10, 64)
|
||||||
|
if err != nil || ack < -1 {
|
||||||
|
return protocol.WriteResponseFrame(conn, requestID, []byte("ERR invalid ack"))
|
||||||
|
}
|
||||||
|
want, err := strconv.ParseUint(parts[4], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return protocol.WriteResponseFrame(conn, requestID, []byte("ERR invalid want"))
|
||||||
|
}
|
||||||
|
offset, err := strconv.Atoi(parts[5])
|
||||||
|
if err != nil || offset < 0 {
|
||||||
|
return protocol.WriteResponseFrame(conn, requestID, []byte("ERR invalid offset"))
|
||||||
|
}
|
||||||
|
limit, err := strconv.Atoi(parts[6])
|
||||||
|
if err != nil || limit < 1 {
|
||||||
|
return protocol.WriteResponseFrame(conn, requestID, []byte("ERR invalid limit"))
|
||||||
|
}
|
||||||
|
if limit > maxChunk {
|
||||||
|
limit = maxChunk
|
||||||
|
}
|
||||||
|
if debug != nil && debug.enabled {
|
||||||
|
debug.pullRequests.Add(1)
|
||||||
|
debug.chunkf("CPULL id=%s ack=%d want=%d offset=%d limit=%d", parts[2], ack, want, offset, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, total, eof, final, waitExpired, err := s.pull(want, ack, offset, limit, pollWait)
|
||||||
|
if err != nil {
|
||||||
|
return protocol.WriteResponseFrame(conn, requestID, []byte("ERR "+err.Error()))
|
||||||
|
}
|
||||||
|
if waitExpired {
|
||||||
|
if debug != nil && debug.enabled {
|
||||||
|
debug.waitRecords.Add(1)
|
||||||
|
debug.chunkf("CPULL id=%s want=%d -> WAIT", parts[2], want)
|
||||||
|
}
|
||||||
|
return protocol.WriteResponseFrame(conn, requestID, []byte("WAIT"))
|
||||||
|
}
|
||||||
|
if eof {
|
||||||
|
if debug != nil {
|
||||||
|
debug.chunkf("CPULL id=%s want=%d -> EOF final=%d", parts[2], want, final)
|
||||||
|
}
|
||||||
|
return protocol.WriteResponseFrame(conn, requestID, []byte(fmt.Sprintf("EOF %d", final)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if debug != nil && debug.enabled {
|
||||||
|
debug.dataRecords.Add(1)
|
||||||
|
debug.chunkf("DATA id=%s seq=%d offset=%d bytes=%d total=%d", parts[2], want, offset, len(data), total)
|
||||||
|
}
|
||||||
|
prefix := []byte(fmt.Sprintf("DATA %d %d %d ", want, offset, total))
|
||||||
|
out := make([]byte, len(prefix)+len(data))
|
||||||
|
copy(out, prefix)
|
||||||
|
copy(out[len(prefix):], data)
|
||||||
|
return protocol.WriteResponseFrame(conn, requestID, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
if bytes.HasPrefix(payload, []byte("CCLOSE ")) {
|
||||||
|
parts := strings.Fields(string(payload))
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return protocol.WriteResponseFrame(conn, requestID, []byte("ERR bad CCLOSE"))
|
||||||
|
}
|
||||||
|
if !tokenEqual(decodeWireToken(parts[1]), token) {
|
||||||
|
return protocol.WriteResponseFrame(conn, requestID, []byte("ERR authentication failed"))
|
||||||
|
}
|
||||||
|
manager.remove(parts[2])
|
||||||
|
if debug != nil && debug.enabled {
|
||||||
|
debug.sessionsClosed.Add(1)
|
||||||
|
debug.activeSessions.Add(-1)
|
||||||
|
debug.logf("SESSION CLOSE id=%s peer=%v active_sessions=%d", parts[2], conn.RemoteAddr(), manager.count())
|
||||||
|
debug.chunkf("CCLOSE id=%s -> CLOSED", parts[2])
|
||||||
|
}
|
||||||
|
return protocol.WriteResponseFrame(conn, requestID, []byte("CLOSED"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return protocol.WriteResponseFrame(conn, requestID, []byte("ERR unknown chunk command"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Wire detection and the XOR connection loop.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// prefixedConn replays bytes already consumed for wire detection before falling
|
||||||
|
// through to the socket. Using a plain bufio.Reader would be wrong here: the
|
||||||
|
// TUNNEL path relays the raw connection, so anything the detector buffered
|
||||||
|
// beyond the magic would be lost.
|
||||||
|
type prefixedConn struct {
|
||||||
|
net.Conn
|
||||||
|
r io.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *prefixedConn) Read(b []byte) (int, error) { return p.r.Read(b) }
|
||||||
|
|
||||||
|
// sniffWire reads the two magic bytes and reports whether this connection
|
||||||
|
// speaks the legacy XOR framing. The returned conn replays them.
|
||||||
|
func sniffWire(conn net.Conn) (net.Conn, bool, error) {
|
||||||
|
var magic [2]byte
|
||||||
|
if _, err := io.ReadFull(conn, magic[:]); err != nil {
|
||||||
|
return conn, false, err
|
||||||
|
}
|
||||||
|
replayed := &prefixedConn{Conn: conn, r: io.MultiReader(bytes.NewReader(magic[:]), conn)}
|
||||||
|
return replayed, magic[0] == 'U' && magic[1] == 'P', nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleXOR serves one connection speaking UP/OK + XOR 0xAD: the v4 chunk
|
||||||
|
// commands, plus the TUNNEL/TUNNEL2 stream commands.
|
||||||
|
func handleXOR(
|
||||||
|
conn net.Conn,
|
||||||
|
token string,
|
||||||
|
allowPrivate bool,
|
||||||
|
cache *dnsCache,
|
||||||
|
tcpBuffer int,
|
||||||
|
manager *chunkManager,
|
||||||
|
chunkMax int,
|
||||||
|
chunkBuffered int,
|
||||||
|
chunkPollWait time.Duration,
|
||||||
|
debug *serverDebug,
|
||||||
|
) {
|
||||||
|
for {
|
||||||
|
_ = conn.SetDeadline(time.Now().Add(20 * time.Second))
|
||||||
|
|
||||||
|
requestID, _, payload, err := protocol.ReadRequestFrame(conn)
|
||||||
|
if err != nil {
|
||||||
|
if debug != nil && debug.enabled && err != io.EOF {
|
||||||
|
debug.errorf("peer=%v read XOR request: %v", conn.RemoteAddr(), err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if isChunkCommand(payload) {
|
||||||
|
if err := processChunkCommand(
|
||||||
|
conn, requestID, payload, token, allowPrivate, cache, tcpBuffer,
|
||||||
|
manager, chunkMax, chunkBuffered, chunkPollWait, debug,
|
||||||
|
); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Fields(string(payload))
|
||||||
|
transport := "xor"
|
||||||
|
if len(parts) == 4 && parts[0] == "TUNNEL" {
|
||||||
|
transport = "xor"
|
||||||
|
} else if len(parts) == 5 && parts[0] == "TUNNEL2" {
|
||||||
|
transport = strings.ToLower(parts[4])
|
||||||
|
if transport != "raw" && transport != "xor" {
|
||||||
|
_ = protocol.WriteResponseFrame(conn, requestID, []byte("ERR transport must be RAW or XOR"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_ = protocol.WriteResponseFrame(conn, requestID, []byte("ERR expected TUNNEL, TUNNEL2, or chunk command"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !tokenEqual(parts[1], token) {
|
||||||
|
_ = protocol.WriteResponseFrame(conn, requestID, []byte("ERR authentication failed"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
port, err := strconv.Atoi(parts[3])
|
||||||
|
if err != nil || port < 1 || port > 65535 {
|
||||||
|
_ = protocol.WriteResponseFrame(conn, requestID, []byte("ERR invalid port"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if debug != nil && debug.enabled {
|
||||||
|
debug.logf("TUNNEL peer=%v target=%s:%d transport=%s", conn.RemoteAddr(), parts[2], port, transport)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
target, err := dialTarget(ctx, parts[2], port, allowPrivate, cache, tcpBuffer)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
if debug != nil && debug.enabled {
|
||||||
|
debug.errorf("TUNNEL target=%s:%d connect failed: %v", parts[2], port, err)
|
||||||
|
}
|
||||||
|
_ = protocol.WriteResponseFrame(conn, requestID, []byte("ERR "+err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer target.Close()
|
||||||
|
|
||||||
|
if err := protocol.WriteResponseFrame(conn, requestID, []byte("CONNECTED")); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = conn.SetDeadline(time.Time{})
|
||||||
|
if transport == "raw" {
|
||||||
|
protocol.RelayRaw(conn, target)
|
||||||
|
} else {
|
||||||
|
protocol.RelayXOR(conn, target)
|
||||||
|
}
|
||||||
|
if debug != nil && debug.enabled {
|
||||||
|
debug.logf("TUNNEL closed peer=%v target=%s:%d transport=%s", conn.RemoteAddr(), parts[2], port, transport)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -0,0 +1,790 @@
|
|||||||
|
// Package xorchunk is the LiteVPN v4 XOR chunk transport, carried over verbatim
|
||||||
|
// so DragonTCP can speak the legacy UP/OK + XOR 0xAD wire on networks that pass
|
||||||
|
// it but reject the newer binary records.
|
||||||
|
//
|
||||||
|
// It lives in its own package purely to avoid symbol collisions with the binary
|
||||||
|
// transport in package main, which uses many of the same names.
|
||||||
|
package xorchunk
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"dragontcp/internal/protocol"
|
||||||
|
)
|
||||||
|
|
||||||
|
// requestCounter correlates UP request frames with their OK responses. It lived
|
||||||
|
// in v4's main.go; the transport needs it, so it moves in here.
|
||||||
|
var requestCounter atomic.Uint32
|
||||||
|
|
||||||
|
// NewOptions builds the transport options from the values the CLI already
|
||||||
|
// parses, keeping the struct fields unexported as in the original.
|
||||||
|
func NewOptions(startSize, minSize, maxSize int, adaptive bool, adaptSuccesses int, adaptLog bool,
|
||||||
|
pollers, reconnectEvery int, pollDelay, txnTimeout time.Duration, tcpBuffer int) Options {
|
||||||
|
return Options{
|
||||||
|
startSize: startSize,
|
||||||
|
minSize: minSize,
|
||||||
|
maxSize: maxSize,
|
||||||
|
adaptive: adaptive,
|
||||||
|
adaptSuccesses: adaptSuccesses,
|
||||||
|
adaptLog: adaptLog,
|
||||||
|
pollers: pollers,
|
||||||
|
reconnectEvery: reconnectEvery,
|
||||||
|
pollDelay: pollDelay,
|
||||||
|
txnTimeout: txnTimeout,
|
||||||
|
tcpBuffer: tcpBuffer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Options struct {
|
||||||
|
startSize int
|
||||||
|
minSize int
|
||||||
|
maxSize int
|
||||||
|
adaptive bool
|
||||||
|
adaptSuccesses int
|
||||||
|
adaptLog bool
|
||||||
|
pollers int
|
||||||
|
reconnectEvery int
|
||||||
|
pollDelay time.Duration
|
||||||
|
txnTimeout time.Duration
|
||||||
|
tcpBuffer int
|
||||||
|
}
|
||||||
|
|
||||||
|
func wireToken(token string) string {
|
||||||
|
if token == "" {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
type adaptiveSizer struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
name string
|
||||||
|
current int
|
||||||
|
min int
|
||||||
|
max int
|
||||||
|
adaptive bool
|
||||||
|
adaptSuccesses int
|
||||||
|
successes int
|
||||||
|
good int
|
||||||
|
bad int
|
||||||
|
logChanges bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAdaptiveSizer(name string, opts Options) *adaptiveSizer {
|
||||||
|
start := opts.startSize
|
||||||
|
if start < opts.minSize {
|
||||||
|
start = opts.minSize
|
||||||
|
}
|
||||||
|
if start > opts.maxSize {
|
||||||
|
start = opts.maxSize
|
||||||
|
}
|
||||||
|
return &adaptiveSizer{
|
||||||
|
name: name,
|
||||||
|
current: start,
|
||||||
|
min: opts.minSize,
|
||||||
|
max: opts.maxSize,
|
||||||
|
adaptive: opts.adaptive,
|
||||||
|
adaptSuccesses: opts.adaptSuccesses,
|
||||||
|
logChanges: opts.adaptLog,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *adaptiveSizer) Current() int {
|
||||||
|
s.mu.Lock()
|
||||||
|
n := s.current
|
||||||
|
s.mu.Unlock()
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *adaptiveSizer) Success(attempted int) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if !s.adaptive || s.current >= s.max {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Ignore stale successes from records that were already in flight when
|
||||||
|
// another worker changed the shared size.
|
||||||
|
if attempted != s.current {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if attempted > s.good {
|
||||||
|
s.good = attempted
|
||||||
|
}
|
||||||
|
s.successes++
|
||||||
|
|
||||||
|
growAfter := s.adaptSuccesses
|
||||||
|
// When we have converged close to a known failure boundary, stay stable
|
||||||
|
// longer before probing again. This also lets us discover later network
|
||||||
|
// improvements without constantly oscillating around the boundary.
|
||||||
|
if s.bad > 0 && s.bad-s.good <= 32 {
|
||||||
|
growAfter *= 8
|
||||||
|
}
|
||||||
|
if s.successes < growAfter {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.successes = 0
|
||||||
|
|
||||||
|
old := s.current
|
||||||
|
var next int
|
||||||
|
if s.bad > old+1 {
|
||||||
|
// Binary-search the gap between known-good and known-bad sizes.
|
||||||
|
next = old + (s.bad-old)/2
|
||||||
|
} else {
|
||||||
|
// Either there is no known ceiling, or we have stayed stable long enough
|
||||||
|
// at it to probe the network again in case conditions improved.
|
||||||
|
if s.bad > 0 {
|
||||||
|
s.bad = 0
|
||||||
|
}
|
||||||
|
step := old / 4
|
||||||
|
if step < 32 {
|
||||||
|
step = 32
|
||||||
|
}
|
||||||
|
next = old + step
|
||||||
|
}
|
||||||
|
|
||||||
|
if next > s.max {
|
||||||
|
next = s.max
|
||||||
|
}
|
||||||
|
if next <= old {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.current = next
|
||||||
|
|
||||||
|
if s.logChanges {
|
||||||
|
fmt.Printf("adaptive %s chunk: %d -> %d after stable success\n", s.name, old, next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *adaptiveSizer) Failure(attempted int) (old, next int) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
old = s.current
|
||||||
|
|
||||||
|
if !s.adaptive {
|
||||||
|
return old, old
|
||||||
|
}
|
||||||
|
// Multiple pollers can fail on the same oversized value at once. Only the
|
||||||
|
// first failure for the current value is allowed to reduce it.
|
||||||
|
if attempted != s.current {
|
||||||
|
return old, old
|
||||||
|
}
|
||||||
|
s.successes = 0
|
||||||
|
|
||||||
|
if s.bad == 0 || attempted < s.bad {
|
||||||
|
s.bad = attempted
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.good > 0 && s.good < attempted {
|
||||||
|
// Return directly to the last size that was proven to work.
|
||||||
|
next = s.good
|
||||||
|
} else {
|
||||||
|
// A previously-good value just failed, so conditions worsened. Forget
|
||||||
|
// the old lower bound and use multiplicative decrease.
|
||||||
|
s.good = 0
|
||||||
|
next = attempted / 2
|
||||||
|
}
|
||||||
|
if next < s.min {
|
||||||
|
next = s.min
|
||||||
|
}
|
||||||
|
if next >= attempted && attempted > s.min {
|
||||||
|
next = attempted - 1
|
||||||
|
}
|
||||||
|
if next < s.min {
|
||||||
|
next = s.min
|
||||||
|
}
|
||||||
|
s.current = next
|
||||||
|
|
||||||
|
if s.logChanges && next != old {
|
||||||
|
fmt.Printf("adaptive %s chunk: %d -> %d after transport failure\n", s.name, old, next)
|
||||||
|
}
|
||||||
|
return old, next
|
||||||
|
}
|
||||||
|
|
||||||
|
type txnLane struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
serverAddr string
|
||||||
|
tcpBuffer int
|
||||||
|
reconnectEvery int
|
||||||
|
timeout time.Duration
|
||||||
|
conn net.Conn
|
||||||
|
count int
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTxnLane(serverAddr string, tcpBuffer, reconnectEvery int, timeout time.Duration) *txnLane {
|
||||||
|
return &txnLane{
|
||||||
|
serverAddr: serverAddr,
|
||||||
|
tcpBuffer: tcpBuffer,
|
||||||
|
reconnectEvery: reconnectEvery,
|
||||||
|
timeout: timeout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *txnLane) closeLocked() {
|
||||||
|
if l.conn != nil {
|
||||||
|
_ = l.conn.Close()
|
||||||
|
l.conn = nil
|
||||||
|
}
|
||||||
|
l.count = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *txnLane) Close() {
|
||||||
|
l.mu.Lock()
|
||||||
|
l.closed = true
|
||||||
|
l.closeLocked()
|
||||||
|
l.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *txnLane) ensureConn() error {
|
||||||
|
if l.closed {
|
||||||
|
return net.ErrClosed
|
||||||
|
}
|
||||||
|
if l.conn != nil && (l.reconnectEvery <= 0 || l.count < l.reconnectEvery) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
l.closeLocked()
|
||||||
|
d := net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
|
||||||
|
conn, err := d.Dial("tcp", l.serverAddr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
protocol.TuneTCP(conn)
|
||||||
|
protocol.TuneTCPBuffer(conn, l.tcpBuffer)
|
||||||
|
l.conn = conn
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do performs exactly one framed transaction. Higher layers decide whether a
|
||||||
|
// failed data record should be retried at a smaller adaptive size.
|
||||||
|
func (l *txnLane) Do(payload []byte) ([]byte, error) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
if err := l.ensureConn(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := l.timeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 5 * time.Second
|
||||||
|
}
|
||||||
|
_ = l.conn.SetDeadline(time.Now().Add(timeout))
|
||||||
|
requestID := requestCounter.Add(1)
|
||||||
|
|
||||||
|
if err := protocol.WriteRequestFrame(l.conn, requestID, payload); err != nil {
|
||||||
|
l.closeLocked()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
responseID, response, err := protocol.ReadResponseFrame(l.conn)
|
||||||
|
if err != nil {
|
||||||
|
l.closeLocked()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if responseID != requestID {
|
||||||
|
l.closeLocked()
|
||||||
|
return nil, fmt.Errorf("request ID mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
l.count++
|
||||||
|
_ = l.conn.SetDeadline(time.Time{})
|
||||||
|
if l.reconnectEvery > 0 && l.count >= l.reconnectEvery {
|
||||||
|
// For restrictive TCP/53 networks, reconnectEvery=1 must really mean
|
||||||
|
// one request/response per TCP connection. Close immediately after
|
||||||
|
// receiving the response rather than waiting for the next request.
|
||||||
|
l.closeLocked()
|
||||||
|
}
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func doControl(lane *txnLane, payload []byte) ([]byte, error) {
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 0; attempt < 5; attempt++ {
|
||||||
|
resp, err := lane.Do(payload)
|
||||||
|
if err == nil {
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
lastErr = err
|
||||||
|
time.Sleep(time.Duration(attempt+1) * 40 * time.Millisecond)
|
||||||
|
}
|
||||||
|
return nil, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
type chunkResult struct {
|
||||||
|
seq uint64
|
||||||
|
data []byte
|
||||||
|
final uint64
|
||||||
|
eof bool
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type chunkConn struct {
|
||||||
|
serverAddr string
|
||||||
|
token string
|
||||||
|
sid string
|
||||||
|
opts Options
|
||||||
|
|
||||||
|
pushLane *txnLane
|
||||||
|
pullLanes []*txnLane
|
||||||
|
|
||||||
|
upSizer *adaptiveSizer
|
||||||
|
downSizer *adaptiveSizer
|
||||||
|
serverMax int
|
||||||
|
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
once sync.Once
|
||||||
|
|
||||||
|
writeMu sync.Mutex
|
||||||
|
upSeq uint64
|
||||||
|
|
||||||
|
claim atomic.Uint64
|
||||||
|
ack atomic.Int64
|
||||||
|
|
||||||
|
results chan chunkResult
|
||||||
|
workers sync.WaitGroup
|
||||||
|
|
||||||
|
readMu sync.Mutex
|
||||||
|
pending map[uint64][]byte
|
||||||
|
nextRead uint64
|
||||||
|
current []byte
|
||||||
|
currentSeq uint64
|
||||||
|
finalKnown bool
|
||||||
|
finalSeq uint64
|
||||||
|
terminalErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomSessionID() (string, error) {
|
||||||
|
var b [16]byte
|
||||||
|
if _, err := rand.Read(b[:]); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b[:]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Open(serverAddr, token, targetHost string, targetPort int, opts Options) (net.Conn, error) {
|
||||||
|
if opts.minSize < 32 {
|
||||||
|
opts.minSize = 32
|
||||||
|
}
|
||||||
|
if opts.maxSize < opts.minSize {
|
||||||
|
opts.maxSize = opts.minSize
|
||||||
|
}
|
||||||
|
if opts.maxSize > protocol.MaxChunkPayload {
|
||||||
|
opts.maxSize = protocol.MaxChunkPayload
|
||||||
|
}
|
||||||
|
if opts.startSize < opts.minSize {
|
||||||
|
opts.startSize = opts.minSize
|
||||||
|
}
|
||||||
|
if opts.startSize > opts.maxSize {
|
||||||
|
opts.startSize = opts.maxSize
|
||||||
|
}
|
||||||
|
if opts.adaptSuccesses < 1 {
|
||||||
|
opts.adaptSuccesses = 64
|
||||||
|
}
|
||||||
|
if opts.pollers < 1 {
|
||||||
|
opts.pollers = 1
|
||||||
|
}
|
||||||
|
if opts.pollers > 128 {
|
||||||
|
opts.pollers = 128
|
||||||
|
}
|
||||||
|
if opts.txnTimeout <= 0 {
|
||||||
|
opts.txnTimeout = 5 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
sid, err := randomSessionID()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
c := &chunkConn{
|
||||||
|
serverAddr: serverAddr,
|
||||||
|
token: token,
|
||||||
|
sid: sid,
|
||||||
|
opts: opts,
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
results: make(chan chunkResult, opts.pollers*4),
|
||||||
|
pending: make(map[uint64][]byte, opts.pollers*2),
|
||||||
|
}
|
||||||
|
c.ack.Store(-1)
|
||||||
|
c.upSizer = newAdaptiveSizer("upload", opts)
|
||||||
|
c.downSizer = newAdaptiveSizer("download", opts)
|
||||||
|
|
||||||
|
c.pushLane = newTxnLane(serverAddr, opts.tcpBuffer, opts.reconnectEvery, opts.txnTimeout)
|
||||||
|
|
||||||
|
openPayload := []byte(fmt.Sprintf(
|
||||||
|
"COPEN %s %s %s %d",
|
||||||
|
wireToken(token), sid, targetHost, targetPort,
|
||||||
|
))
|
||||||
|
resp, err := doControl(c.pushLane, openPayload)
|
||||||
|
if err != nil {
|
||||||
|
c.pushLane.Close()
|
||||||
|
cancel()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
fields := strings.Fields(string(resp))
|
||||||
|
if len(fields) != 2 || fields[0] != "OPENED" {
|
||||||
|
c.pushLane.Close()
|
||||||
|
cancel()
|
||||||
|
return nil, fmt.Errorf("%s", resp)
|
||||||
|
}
|
||||||
|
serverMax, err := strconv.Atoi(fields[1])
|
||||||
|
if err != nil || serverMax < 32 {
|
||||||
|
c.pushLane.Close()
|
||||||
|
cancel()
|
||||||
|
return nil, fmt.Errorf("bad OPENED response: %q", resp)
|
||||||
|
}
|
||||||
|
c.serverMax = serverMax
|
||||||
|
if serverMax < c.opts.maxSize {
|
||||||
|
c.opts.maxSize = serverMax
|
||||||
|
c.upSizer.max = serverMax
|
||||||
|
c.downSizer.max = serverMax
|
||||||
|
if c.upSizer.current > serverMax {
|
||||||
|
c.upSizer.current = serverMax
|
||||||
|
}
|
||||||
|
if c.downSizer.current > serverMax {
|
||||||
|
c.downSizer.current = serverMax
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.pullLanes = make([]*txnLane, opts.pollers)
|
||||||
|
for i := 0; i < opts.pollers; i++ {
|
||||||
|
lane := newTxnLane(serverAddr, opts.tcpBuffer, opts.reconnectEvery, opts.txnTimeout)
|
||||||
|
c.pullLanes[i] = lane
|
||||||
|
c.workers.Add(1)
|
||||||
|
go c.pullWorker(lane)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDataResponse(resp []byte) (seq uint64, offset int, total int, data []byte, err error) {
|
||||||
|
if len(resp) < 6 || string(resp[:5]) != "DATA " {
|
||||||
|
return 0, 0, 0, nil, fmt.Errorf("not DATA")
|
||||||
|
}
|
||||||
|
|
||||||
|
rest := resp[5:]
|
||||||
|
fields := make([][]byte, 0, 3)
|
||||||
|
start := 0
|
||||||
|
for i := 0; i < len(rest) && len(fields) < 3; i++ {
|
||||||
|
if rest[i] == ' ' {
|
||||||
|
fields = append(fields, rest[start:i])
|
||||||
|
start = i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(fields) != 3 {
|
||||||
|
return 0, 0, 0, nil, fmt.Errorf("bad DATA response")
|
||||||
|
}
|
||||||
|
|
||||||
|
seq, err = strconv.ParseUint(string(fields[0]), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, 0, nil, err
|
||||||
|
}
|
||||||
|
offset, err = strconv.Atoi(string(fields[1]))
|
||||||
|
if err != nil || offset < 0 {
|
||||||
|
return 0, 0, 0, nil, fmt.Errorf("bad DATA offset")
|
||||||
|
}
|
||||||
|
total, err = strconv.Atoi(string(fields[2]))
|
||||||
|
if err != nil || total < 0 {
|
||||||
|
return 0, 0, 0, nil, fmt.Errorf("bad DATA total")
|
||||||
|
}
|
||||||
|
|
||||||
|
// start now points immediately after the third separator.
|
||||||
|
return seq, offset, total, rest[start:], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *chunkConn) pullWorker(lane *txnLane) {
|
||||||
|
defer c.workers.Done()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
seq := c.claim.Add(1) - 1
|
||||||
|
offset := 0
|
||||||
|
var assembled []byte
|
||||||
|
consecutiveMinFailures := 0
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := c.downSizer.Current()
|
||||||
|
ack := c.ack.Load()
|
||||||
|
payload := []byte(fmt.Sprintf(
|
||||||
|
"CPULL %s %s %d %d %d %d",
|
||||||
|
wireToken(c.token), c.sid, ack, seq, offset, limit,
|
||||||
|
))
|
||||||
|
|
||||||
|
resp, err := lane.Do(payload)
|
||||||
|
if err != nil {
|
||||||
|
old, next := c.downSizer.Failure(limit)
|
||||||
|
if next == old && next == c.opts.minSize {
|
||||||
|
consecutiveMinFailures++
|
||||||
|
} else {
|
||||||
|
consecutiveMinFailures = 0
|
||||||
|
}
|
||||||
|
if consecutiveMinFailures >= 8 {
|
||||||
|
select {
|
||||||
|
case c.results <- chunkResult{seq: seq, err: fmt.Errorf("download failed at minimum chunk %d: %w", next, err)}:
|
||||||
|
case <-c.ctx.Done():
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(30 * time.Millisecond)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(resp) == "WAIT" {
|
||||||
|
if c.opts.pollDelay > 0 {
|
||||||
|
select {
|
||||||
|
case <-time.After(c.opts.pollDelay):
|
||||||
|
case <-c.ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(string(resp), "ERR ") {
|
||||||
|
select {
|
||||||
|
case c.results <- chunkResult{seq: seq, err: fmt.Errorf("%s", resp)}:
|
||||||
|
case <-c.ctx.Done():
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(string(resp), "EOF ") {
|
||||||
|
n, err := strconv.ParseUint(strings.TrimSpace(string(resp[4:])), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
select {
|
||||||
|
case c.results <- chunkResult{seq: seq, err: err}:
|
||||||
|
case <-c.ctx.Done():
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case c.results <- chunkResult{seq: seq, eof: true, final: n}:
|
||||||
|
case <-c.ctx.Done():
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
gotSeq, gotOffset, total, fragment, err := parseDataResponse(resp)
|
||||||
|
if err != nil {
|
||||||
|
select {
|
||||||
|
case c.results <- chunkResult{seq: seq, err: err}:
|
||||||
|
case <-c.ctx.Done():
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if gotSeq != seq || gotOffset != offset {
|
||||||
|
select {
|
||||||
|
case c.results <- chunkResult{seq: seq, err: fmt.Errorf("DATA position mismatch")}:
|
||||||
|
case <-c.ctx.Done():
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if total > c.serverMax || total < offset+len(fragment) || len(fragment) == 0 {
|
||||||
|
select {
|
||||||
|
case c.results <- chunkResult{seq: seq, err: fmt.Errorf("invalid DATA fragment size")}:
|
||||||
|
case <-c.ctx.Done():
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if assembled == nil {
|
||||||
|
assembled = make([]byte, 0, total)
|
||||||
|
}
|
||||||
|
assembled = append(assembled, fragment...)
|
||||||
|
offset += len(fragment)
|
||||||
|
consecutiveMinFailures = 0
|
||||||
|
c.downSizer.Success(limit)
|
||||||
|
|
||||||
|
if offset == total {
|
||||||
|
select {
|
||||||
|
case c.results <- chunkResult{seq: seq, data: assembled}:
|
||||||
|
case <-c.ctx.Done():
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *chunkConn) Read(p []byte) (int, error) {
|
||||||
|
c.readMu.Lock()
|
||||||
|
defer c.readMu.Unlock()
|
||||||
|
|
||||||
|
for {
|
||||||
|
if len(c.current) > 0 {
|
||||||
|
n := copy(p, c.current)
|
||||||
|
c.current = c.current[n:]
|
||||||
|
if len(c.current) == 0 {
|
||||||
|
c.nextRead++
|
||||||
|
c.ack.Store(int64(c.currentSeq))
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.terminalErr != nil {
|
||||||
|
return 0, c.terminalErr
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.finalKnown && c.nextRead >= c.finalSeq {
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
if data, ok := c.pending[c.nextRead]; ok {
|
||||||
|
delete(c.pending, c.nextRead)
|
||||||
|
c.current = data
|
||||||
|
c.currentSeq = c.nextRead
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
result, ok := <-c.results
|
||||||
|
if !ok {
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
if result.err != nil {
|
||||||
|
c.terminalErr = result.err
|
||||||
|
return 0, result.err
|
||||||
|
}
|
||||||
|
if result.eof {
|
||||||
|
if !c.finalKnown || result.final < c.finalSeq {
|
||||||
|
c.finalKnown = true
|
||||||
|
c.finalSeq = result.final
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if result.seq < c.nextRead {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.pending[result.seq] = result.data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAck(resp []byte, expectedSeq uint64) (int, error) {
|
||||||
|
fields := strings.Fields(string(resp))
|
||||||
|
if len(fields) != 3 || fields[0] != "ACK" {
|
||||||
|
return 0, fmt.Errorf("bad CPUSH response: %q", resp)
|
||||||
|
}
|
||||||
|
seq, err := strconv.ParseUint(fields[1], 10, 64)
|
||||||
|
if err != nil || seq != expectedSeq {
|
||||||
|
return 0, fmt.Errorf("bad CPUSH sequence: %q", resp)
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(fields[2])
|
||||||
|
if err != nil || n <= 0 {
|
||||||
|
return 0, fmt.Errorf("bad CPUSH length: %q", resp)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *chunkConn) Write(p []byte) (int, error) {
|
||||||
|
c.writeMu.Lock()
|
||||||
|
defer c.writeMu.Unlock()
|
||||||
|
|
||||||
|
total := 0
|
||||||
|
consecutiveMinFailures := 0
|
||||||
|
|
||||||
|
for len(p) > 0 {
|
||||||
|
size := c.upSizer.Current()
|
||||||
|
n := size
|
||||||
|
if len(p) < n {
|
||||||
|
n = len(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
seq := c.upSeq
|
||||||
|
prefix := []byte(fmt.Sprintf("CPUSH %s %s %d ", wireToken(c.token), c.sid, seq))
|
||||||
|
payload := make([]byte, len(prefix)+n)
|
||||||
|
copy(payload, prefix)
|
||||||
|
copy(payload[len(prefix):], p[:n])
|
||||||
|
|
||||||
|
resp, err := c.pushLane.Do(payload)
|
||||||
|
if err != nil {
|
||||||
|
old, next := c.upSizer.Failure(size)
|
||||||
|
if next == old && next == c.opts.minSize {
|
||||||
|
consecutiveMinFailures++
|
||||||
|
} else {
|
||||||
|
consecutiveMinFailures = 0
|
||||||
|
}
|
||||||
|
if consecutiveMinFailures >= 8 {
|
||||||
|
return total, fmt.Errorf("upload failed at minimum chunk %d: %w", next, err)
|
||||||
|
}
|
||||||
|
time.Sleep(30 * time.Millisecond)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(string(resp), "ERR ") {
|
||||||
|
return total, fmt.Errorf("%s", resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
accepted, err := parseAck(resp, seq)
|
||||||
|
if err != nil {
|
||||||
|
return total, err
|
||||||
|
}
|
||||||
|
if accepted > len(p) {
|
||||||
|
return total, fmt.Errorf("server ACK length %d exceeds pending write %d", accepted, len(p))
|
||||||
|
}
|
||||||
|
|
||||||
|
c.upSeq++
|
||||||
|
total += accepted
|
||||||
|
p = p[accepted:]
|
||||||
|
consecutiveMinFailures = 0
|
||||||
|
c.upSizer.Success(size)
|
||||||
|
}
|
||||||
|
|
||||||
|
return total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *chunkConn) Close() error {
|
||||||
|
c.once.Do(func() {
|
||||||
|
c.cancel()
|
||||||
|
|
||||||
|
lane := newTxnLane(c.serverAddr, c.opts.tcpBuffer, 1, c.opts.txnTimeout)
|
||||||
|
_, _ = doControl(lane, []byte(fmt.Sprintf("CCLOSE %s %s", wireToken(c.token), c.sid)))
|
||||||
|
lane.Close()
|
||||||
|
|
||||||
|
if c.pushLane != nil {
|
||||||
|
c.pushLane.Close()
|
||||||
|
}
|
||||||
|
for _, lane := range c.pullLanes {
|
||||||
|
lane.Close()
|
||||||
|
}
|
||||||
|
c.workers.Wait()
|
||||||
|
close(c.results)
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *chunkConn) LocalAddr() net.Addr { return dummyAddr("dragontcp-chunk-local") }
|
||||||
|
func (c *chunkConn) RemoteAddr() net.Addr { return dummyAddr("dragontcp-chunk-remote") }
|
||||||
|
func (c *chunkConn) SetDeadline(time.Time) error { return nil }
|
||||||
|
func (c *chunkConn) SetReadDeadline(time.Time) error { return nil }
|
||||||
|
func (c *chunkConn) SetWriteDeadline(time.Time) error { return nil }
|
||||||
|
|
||||||
|
type dummyAddr string
|
||||||
|
|
||||||
|
func (d dummyAddr) Network() string { return "dragontcp-chunk" }
|
||||||
|
func (d dummyAddr) String() string { return string(d) }
|
||||||
Reference in New Issue
Block a user