The NetworkProfile feature was added on 2026-05-28
(app/src/main/java/com/proxyagent/app/nativeagent/quic/NetworkProfile.kt).
The numeric values it ships with (TCP socket buffer, TCP bridge
buffer, QUIC Brutal CC target, QUIC UDP socket buffer, QUIC flow-
control refresh ratio) are NOT arbitrary — they were picked
through five field tests on the same day. Twice during that
process a value choice broke the TCP upload direction in a way
that produced an identical 5 Mbps symptom from opposite extremes.
This report captures every test run with raw numbers and the
applied agent.log line, so a future engineer changing
NetworkProfile.tuning() can:
- See immediately what's been tried and what broke.
- Avoid re-running the same regressions (the 12 MiB and 1 MiB SO_*BUF tests are NOT free — they require a full stop/start cycle, a speedtest, and produce no useful signal beyond "this is still broken").
- Extend the test matrix into the gaps listed under Open questions.
When in doubt, read this BEFORE editing NetworkProfile.tuning().
Best clean run per profile on the Google Pixel 9 Pro XL
(google komodo, Android 16, kernel 6.1.145-android14-11) in
ideal in-apartment conditions:
- Proxy server, proxy agent (the phone), and the Speedtest client all physically in the same apartment in Kremenchuk
- Test client on gigabit Ethernet, agent on Wi-Fi (router ≈2 m away)
- Round-trip from client to phone-exit-IP is ≈2 hops over the in-apartment LAN plus the phone's Wi-Fi link — no WAN/cellular in between
- Speedtest.net Multi-Connection mode through the proxy
- Phone's public exit IP during all three runs: 195.64.231.232 (Vizit-net Kremenchuk)
- Transport: TCP (negotiated by sticky cache on every run)
| Profile | TCP SO_*BUF | Download | Upload | Idle ping | Loaded ↓ ping | Loaded ↑ ping |
|---|---|---|---|---|---|---|
LOW_100 (default) |
1.5 MiB | 331.61 Mbps | 298.95 Mbps | 84 ms | 244 ms | 58 ms |
MID_500 |
2 MiB | 296.22 Mbps | 279.86 Mbps | 34 ms | 202 ms | 56 ms |
HIGH_1000 |
4 MiB | 305.41 Mbps | 259.71 Mbps | 82 ms | 236 ms | 62 ms |
What these numbers tell you:
- All three profiles deliver near-symmetric duplex (≥260 Mbps in both directions) on this channel — no profile leaves throughput on the table.
- MID_500 wins on latency (idle ping 34 ms vs 82–84 ms for the other two), at the cost of a few percent of peak download vs HIGH_1000.
- LOW_100 surprisingly tops the download despite its smaller buffers — likely because the smaller kernel queue keeps TCP's ACK timing tighter and feedback loops faster. Repeated runs would smooth this out; differences within ~5% are inside speedtest-to-speedtest variance.
- Bufferbloat under load: loaded download ping climbs to 202–244 ms on every profile because the test client's gigabit link saturates the proxy direction and the kernel queue inevitably fills. The profile sizes the steady-state queue depth, not what happens during a 30-second flat-out burst.
The full test matrix including the broken runs (12 MiB and 1 MiB SO_*BUF regressions) is in Test runs; the regression analysis is in Findings.
Conditions were deliberately controlled to eliminate variables that aren't the agent's software:
| Element | Setting | Why |
|---|---|---|
Proxy server (proxy-server-go) |
Same apartment as agent, Kremenchuk | Removes WAN latency / packet loss from the agent↔server tunnel — any latency observed comes from the software, not the link |
| Test client (Speedtest.net) | Same apartment, gigabit Ethernet | Removes test-client uplink as a bottleneck |
| Phone uplink | Wi-Fi (not cellular) | Removes cellular modem variability (RAT switches, IMSI handoff, signal fluctuation) — keeps the receive side stable |
| Speedtest mode | Multi-connection (default) | Matches how real proxy clients open many parallel target dials |
| Transport | TCP (auto-negotiated) | Every test session ended up on TCP — QUIC parameters in the profile were NOT exercised (see Open questions) |
| Engine | NATIVE | BINARY engine ignores the profile entirely (see BINARIES.md §2) |
| Public exit IP | 195.64.231.232 (Vizit-net Kremenchuk) for the in-apartment path; 188.239.123.47 (ZasNet Oleksandriya) for one earlier test on a different uplink | Verified each run via the speedtest result IP field |
Two devices were used:
- Samsung Galaxy S Fold 7 —
samsung q7q, Android 16, kernel 6.6.98-android15-8. Used for the initial HIGH_1000 regression discovery. - Google Pixel 9 Pro XL —
google komodo, Android 16, kernel 6.1.145-android14-11. Used for Tests 2–5 (HIGH_1000 through LOW_100 bisection).
Same kernel major (Linux 6.x) but different vendor patches and
different net.core.{r,s}mem_max configuration.
Each test cycle:
-
Open Settings, change
Network optimizationSpinner. -
Hit Save — Toast confirms
Saved — restart agent to apply. -
STOP service, then START — the new profile applies on the next supervisor cycle (parameters ride the QUIC handshake and the TCP socket pre-connect hint; neither can be live- reloaded).
-
Wait for
Status: CONNECTED+Transport: WIFI. -
Verify the applied values via
agent.log— every start emits one structured line:level=INFO msg="network profile" user_choice=<profile> tcp_socket_buf=<bytes> tcp_bridge_buf=<bytes> quic_brutal_target_mbps=<int> quic_udp_socket_buf=<bytes> quic_window_headroom_ratio=<double> -
Run Speedtest.net Multi-Connection mode through the proxy from the test client. Record Download/Upload Mbps, idle Ping, loaded Ping (down/up).
-
Export the agent log (kbn
SAVEin the app's logs panel) and screenshot the speedtest result.
The agent log lines for each run are preserved verbatim in
docs/test-logs/ (not in this repo as of this report — see
Open questions for follow-up).
Device: Samsung Galaxy S Fold 7 (samsung q7q, Android 16,
kernel 6.6.98-android15-8). App version 1.0.110.
Profile applied (from agent.log):
network profile user_choice=HIGH_1000
tcp_socket_buf=12582912 ← 12 MiB
tcp_bridge_buf=262144 ← 256 KiB
quic_brutal_target_mbps=1000
quic_udp_socket_buf=33554432
quic_window_headroom_ratio=0.5
Run 1a — proxy uplink via ZasNet (Oleksandriya), 36 active tunnels:
- Download: 76.15 Mbps
- Upload: 5.07 Mbps ← BROKEN
- Ping: 169 ms
- Public IP: 188.239.123.47
Run 1b — same profile, switched to in-apartment Kremenchuk uplink (Vizit-net), to remove WAN noise:
- Download: 298.44 Mbps
- Upload: 24.09 Mbps ← BROKEN (more visible against the better channel — duplex symmetry should have been near-perfect with ping 33 ms in the same city)
- Ping: 33 ms
- Public IP: 195.64.231.232
Outcome: REGRESSION. TCP upload tanked. Hypothesis formed:
setting SO_SNDBUF manually disables tcp_wmem auto-tuning;
requesting more than net.core.wmem_max (typically 4–8 MiB on
Android) clamps the request AND leaves the effective send
window below where auto-tune would have grown it. The
TCP receive side (SO_RCVBUF) is unaffected because
tcp_rmem clamps differently — hence download stays clean
while upload collapses.
Device: Google Pixel 9 Pro XL (google komodo, Android 16,
kernel 6.1.145-android14-11). App version 1.0.111.
Profile applied:
network profile user_choice=HIGH_1000
tcp_socket_buf=4194304 ← 4 MiB (was 12 MiB)
tcp_bridge_buf=262144
quic_brutal_target_mbps=1000
quic_udp_socket_buf=33554432
quic_window_headroom_ratio=0.5
The 4 MiB value comes from the pre-profile hardcoded
SOCKET_BUFFER_HINT_BYTES constant. It matches exactly the
behaviour the codebase had before the preset existed and is
prod-validated across many devices.
Run conditions: 78 active tunnels, Vizit-net uplink, Pixel on Wi-Fi to a router 2 m away.
Result:
- Download: 305.41 Mbps
- Upload: 259.71 Mbps
- Idle ping: 82 ms
- Loaded ping: ↓ 236 ms, ↑ 62 ms
Outcome: CLEAN. TCP duplex restored. Verifies the hypothesis on a second device (Pixel, kernel 6.1, totally different vendor patch set from the Samsung in Test 1).
Same device and conditions as Test 2.
Profile applied:
network profile user_choice=MID_500
tcp_socket_buf=2097152 ← 2 MiB
tcp_bridge_buf=131072 ← 128 KiB
quic_brutal_target_mbps=500
quic_udp_socket_buf=16777216
quic_window_headroom_ratio=0.6
Run conditions: 88 active tunnels.
Result:
- Download: 296.22 Mbps
- Upload: 279.86 Mbps
- Idle ping: 34 ms ← lowest of any test
- Loaded ping: ↓ 202 ms, ↑ 56 ms
Outcome: CLEAN, and notably the lowest latency of any profile tested on this channel. Throughput within noise of HIGH_1000 (≤3% lower on download, ≤8% higher on upload). Demonstrates the latency win is real — half the SO_*BUF means roughly half the worst-case kernel queue depth.
Same device and conditions as Test 2-3.
Profile applied:
network profile user_choice=LOW_100
tcp_socket_buf=1048576 ← 1 MiB
tcp_bridge_buf=65536 ← 64 KiB
quic_brutal_target_mbps=100
quic_udp_socket_buf=4194304
quic_window_headroom_ratio=0.75
Run conditions: 30 active tunnels.
Result:
- Download: 188.10 Mbps
- Upload: 5.04 Mbps ← BROKEN
- Idle ping: 33 ms
- Loaded ping: ↓ 33 ms, ↑ 33 ms (low because the link isn't saturating — upload throughput cap is too low to bloat the buffer)
Outcome: SYMMETRIC REGRESSION. The 5.04 Mbps upload is
within 0.6% of the 5.07 Mbps Samsung saw at 12 MiB. Two
completely different SO_*BUF values, two different devices,
two different kernel versions — same failure mode. The
mechanism has to be symmetric around Android's tcp_wmem
auto-tune default zone.
Hypothesis updated: explicitly setting SO_SNDBUF below
tcp_wmem default ALSO disables auto-tuning. With 1 MiB
manually set, tcp_adv_win_scale overhead (default 1, i.e.
quarter of the buffer reserved for ack/window accounting)
eats enough of the small buffer that the effective sending
window under multi-flow load can't grow past ~5 Mbps.
Same device. App version 1.0.112.
To pin the lower edge of the safe zone, the next try was a bisection at 1.5 MiB — midpoint between broken (1 MiB) and known-safe (2 MiB, from Test 3).
Profile applied:
network profile user_choice=LOW_100
tcp_socket_buf=1572864 ← 1.5 MiB (= 1_536 × 1024)
tcp_bridge_buf=65536
quic_brutal_target_mbps=100
quic_udp_socket_buf=4194304
quic_window_headroom_ratio=0.75
Run conditions: 70 active tunnels.
Result:
- Download: 331.61 Mbps
- Upload: 298.95 Mbps
- Idle ping: 84 ms
- Loaded ping: ↓ 244 ms, ↑ 58 ms
Outcome: CLEAN. The lower edge of the safe zone sits between 1.0 and 1.5 MiB, closer to 1.5 than we initially assumed. Pinning LOW_100 at 1.5 MiB gives a small bufferbloat improvement over 2 MiB (~120 ms worst case at 100 Mbps vs ~160 ms at 2 MiB) while staying safely above the regression threshold.
| # | Device | Profile | SO_*BUF | bridge | DL | UL | Idle ping | Verdict |
|---|---|---|---|---|---|---|---|---|
| 1a | Samsung q7q | HIGH_1000 | 12 MiB | 256 KiB | 76 | 5 | 169 | UL BROKEN |
| 1b | Samsung q7q | HIGH_1000 | 12 MiB | 256 KiB | 298 | 24 | 33 | UL BROKEN |
| 2 | Pixel komodo | HIGH_1000 | 4 MiB | 256 KiB | 305 | 260 | 82 | CLEAN |
| 3 | Pixel komodo | MID_500 | 2 MiB | 128 KiB | 296 | 280 | 34 | CLEAN ★ |
| 4 | Pixel komodo | LOW_100 | 1 MiB | 64 KiB | 188 | 5 | 33 | UL BROKEN |
| 5 | Pixel komodo | LOW_100 | 1.5 MiB | 64 KiB | 332 | 299 | 84 | CLEAN |
★ Test 3 = best ping while keeping symmetric throughput. If a fourth profile slot ever lands (a "balanced low-latency" preset), this is the configuration to start from.
| Boundary | Value | Symptom outside |
|---|---|---|
| Upper edge | 4 MiB | 12 MiB → ~5 Mbps upload (Samsung) |
| Lower edge | between 1.0 and 1.5 MiB | 1 MiB → ~5 Mbps upload (Pixel) |
Two-sided regression with identical failure mode. The 5.04 / 5.07 / 24 Mbps cluster of broken-upload numbers is too tight to be coincidence — same underlying kernel mechanism fires at both extremes.
Most plausible explanation: SO_SNDBUF set outside the
kernel's tcp_wmem default range (typically 4096 87380 4194304 on Linux 6.x, but ROM-customised on Android) disables
auto-tuning. The effective send window after manual override
tcp_adv_win_scaleoverhead falls below what the BDP demands under multi-flow load. Receive direction is governed by separatetcp_rmemconfig that doesn't trigger the same collapse — hence the asymmetric symptom (download fine, upload broken).
This finding is encoded in NetworkProfile.kt's tuning()
kdoc and in ARCHITECTURE.md §NetworkProfile-driven tuning.
Future changes to the SO_*BUF values MUST stay within this
range or re-run the field tests.
Bridge buffer values (64 / 128 / 256 KiB) were not individually field-tested for failure modes — only the SO_*BUF tests forced choices on bridge buffer because the two scale together in the preset. No bridge-buffer regression was observed in the 1.5–4 MiB SO_*BUF safe zone. Hypothesis: because the bridge buffer lives in userspace and is sized in tens of KiB not MiB, it doesn't interact with the kernel-side TCP auto-tuning that caused the SO_*BUF issues.
The proxy held 30–88 simultaneous TCP tunnels during these tests. Per-flow throughput cap at 1.5 MiB / 100 ms RTT is roughly 120 Mbps single-flow, well below the 332 Mbps total download observed at LOW_100. The aggregate adds up because each tunnel is a separate TCP flow with its own window scaling — proxy traffic patterns mask the per-flow ceiling that would matter for a single big-file download.
If a single-flow scraper / single-stream video case ever becomes a target workload, the SO_*BUF floor at LOW_100 will matter more and this report should be revisited.
Every test session negotiated TCP. The quic_brutal_target_mbps,
quic_udp_socket_buf, and quic_window_headroom_ratio values
in the profile were applied to the configuration but never
ran live traffic. The values shipped are theoretical (sized
to BDP at the profile's target rate × 100 ms RTT, with
window_headroom_ratio chosen for FC update frequency).
QUIC validation is a follow-up.
| Profile | TCP SO_*BUF | TCP bridge | Brutal CC | UDP buf | FC headroom | QUIC SendBuf cap |
|---|---|---|---|---|---|---|
| LOW_100 (default) | 1.5 MiB | 64 KiB | 100 Mbps | 4 MiB | 0.75 | 64 KiB |
| MID_500 | 2 MiB | 128 KiB | 500 Mbps | 16 MiB | 0.60 | 128 KiB |
| HIGH_1000 | 4 MiB | 256 KiB | 1 Gbps | 32 MiB | 0.50 | 256 KiB |
Default LOW_100 picked because most mobile/Wi-Fi uplinks fall
below 100 Mbps in practice, and the smaller kernel queue bounds
bufferbloat tighter. The Test 5 result above (LOW_100 on a
gigabit channel through the proxy → 331/299 Mbps) demonstrates
that multi-flow workloads still saturate fast links at LOW_100,
so users on faster networks aren't penalised by the safe default.
Added in build 120 alongside the QUIC duplex / upload fix series
(see ARCHITECTURE.md "Bounded SendBuffer + auto-RESET stuck
streams" — also tracked here in the new "QUIC validation
2026-05-29" section). Caps per-stream userspace SendBuffer at
the chosen size; bridge threads block on notFull.await()
once the cap is hit. Values track the TCP bridge_buffer_bytes
column intentionally — they describe the same conceptual unit
(per-stream userspace flush size) at the QUIC layer.
The cap value matters for the credit-pin recovery scenario: larger cap = more stuck bytes per stream when peer's MAX_DATA freezes (build-119 with 4 MiB cap showed 78 MB queued across 300 streams = unrecoverable). Picked to mirror kwik's 50 KB default and tracked the TCP bridge buffer sizing for consistency.
These are gaps in the current test data. Each one is a follow- up test someone should run before assuming they know the answer:
Partially resolved on 2026-05-29 (Samsung q7q, HIGH_1000) — the QUIC validation surfaced three independent regressions and fixes are now in code. See the QUIC validation 2026-05-29 section below for the run-by-run capture. Headline:
flow.send_creditpinning at 0 after download speedtest (proxy-server-go MAX_DATA quirk) was fixed via auto-RESET stuck-stream sweeper + RESET_STREAM emission.- 70 ACK/s capped peer's CUBIC growth — dropped to 350 ACK/s
by lowering
IMMEDIATE_ACK_THRESHOLDfrom 10 to 2. - 4 MiB SendBuffer cap allowed 78 MB of stuck bytes after download → tightened to 64/128/256 KB per profile, matching kwik's design.
Result on a healthy path (Euronet Hlobyne, RTT 84 ms): QUIC upload reached 63 Mbps (vs 0 Mbps before, vs 260 Mbps for TCP). On a degraded path (ZasNet Oleksandriya through proxy in same region): QUIC ≈ 7 Mbps both directions — confirmed as peer-side / path-side asymmetry, not agent stack (TCP on the same path also limited).
The QUIC matrix per profile (validate brutalTargetMbps /
udpSocketBufBytes / windowUpdateHeadroomRatio / new
sendBufferMaxBytes) is still incomplete — only HIGH_1000 was
actively exercised. LOW_100 and MID_500 need their own runs.
All tests on Wi-Fi to a local router. Cellular adds:
- Higher RTT (cell tower hop, typically 30-100 ms additional)
- Variable bandwidth (RAT switches LTE↔5G)
- Bursty loss patterns
Profile values were sized for ~100 ms RTT but cellular often exceeds that. LOW_100 might need a SO_*BUF bump on cellular to keep BDP filled; alternatively LOW_100 might prove ideal on cellular because the link's natural rate matches the profile's design point.
Only two devices tested:
- Samsung q7q, Linux 6.6.98 (Test 1)
- Pixel komodo, Linux 6.1.145 (Tests 2–5)
Older kernels (5.x), different vendor net stacks (Xiaomi,
OnePlus, Huawei) may have different net.core.{r,s}mem_max
defaults that shift the safe zone. The 4 MiB upper anchor is
prod-validated broadly (matches the old hardcoded value);
the 1.5 MiB lower edge is from one device.
Each speedtest run is ~30 s. No multi-hour soak. A profile
that works at minute 5 might develop a memory leak or buffer
deadlock at hour 5. Future test: run with MID_500 for 24
hours with a 1-flow-per-minute target connect pattern,
monitor tunnel-open success rate and per-tunnel byte counts
in agent.log.
Speedtest used multi-connection mode (default). Single-flow throughput per profile not measured. A single big file download will see the per-flow cap (~120 Mbps at LOW_100 / 100 ms RTT). Whether that matters depends on workload.
Bridge buffer values (64/128/256 KiB) work alongside SO_*BUF in the safe zone, but were not pushed below 64 KiB or above 256 KiB. Smallest practical bridge buffer might be a few hundred bytes (Nagle interaction at low rates); largest is bounded by per-tunnel memory cost (256 KiB × N_tunnels).
Raw agent.log exports and speedtest screenshots from Tests
1–5 exist as Telegram-shared files (timestamped
proxy-agent-YYYYMMDD-HHMMSS.log). They should be moved into
docs/test-logs/2026-05-28/ so the raw data is preserved
alongside this report.
The original 2026-05-28 matrix exercised TCP only because the
sticky-transport cache happened to negotiate TCP every run.
On 2026-05-29 a separate test series forced QUIC
(.proxyagent_transport deleted between runs) on Samsung q7q,
HIGH_1000 profile. The series uncovered three independent
regressions; fixes are now in code (builds 117 → 120).
Each row corresponds to one test/build cycle. The user ran Speedtest.net Multi-Connection from a separate test client through the proxy.
| Build | Profile | Server | Public IP | DL Mbps | UL Mbps | Disconnect between DL→UL? | Notes |
|---|---|---|---|---|---|---|---|
| 1.0.115 | HIGH_1000 | OPTINET Poltava | 188.239.123.47 (ZasNet) | 176.83 | 4.85 | Yes, 5 s | Original build-97 self-heal firing; symptom was 5 Mbps UL identical to a different failure mode the user remembered from earlier (TCP SO_*BUF=12 MiB outside safe zone). Misidentified at first as same bug — but on ZasNet, TCP also saw ≈5 Mbps UL, suggesting path limit. |
| 1.0.116 | HIGH_1000 | OPTINET Poltava | 188.239.123.47 | 179.29 | 0 | No (we removed self-heal) | "rxIdle gate" added to suppress disconnect — broke the recovery mechanism. quic-go's MAX_DATA pinned at 0 for 60+ seconds, upload stuck at 0 Mbps. Revert candidate. |
| 1.0.117 | HIGH_1000 | OPTINET Poltava | 188.239.123.47 | 195.79 | 0 | No | rxIdle gate removed but bounded SendBuffer (4 MiB) and STOP_SENDING handler added. peer never sent STOP_SENDING → handler never fired → still 0 Mbps UL. Confirmed peer behaviour from log: md_recv=119 stayed flat for 60+s, send_buf_queued accumulated 78 MB across 300 streams. |
| 1.0.118 | HIGH_1000 | OPTINET Poltava | 188.239.123.47 | 195.79 | 0 | No | Added auto-RESET sweeper but trigger was sendBuffer.closed && queuedBytes > 0. Bridge threads were parked inside write() at the cap, never reached close() → sweeper missed them entirely. |
| 1.0.119 | HIGH_1000 | OPTINET Poltava | 188.239.123.47 | 126.00 | 7.72 | No | First working build. Sweeper trigger switched to lastDrainNanos (catches parked-bridge case). 22 streams auto-reset 2 s after download, send_credit jumped from 0 to 12.5 MB, upload phase ran at ~7 Mbps. Path-limited (same as TCP on this uplink). |
| 1.0.120 | HIGH_1000 | OPTINET Poltava | 188.239.123.47 | 132.31 | 7.70 | No | IMMEDIATE_ACK_THRESHOLD dropped from 10 to 2 (≈350 ACK/s instead of 70). No measurable change on this path — confirms the 7 Mbps cap was the path, not our ACK rate. |
| 1.0.120 | HIGH_1000 | Euronet Hlobyne | 195.64.231.232 (Vizit-net) | 45.33 | 63.39 | No | Healthy path validation. Upload ABOVE download — definitively shows the agent stack supports much higher upload than the ZasNet path allows. Path-limited tests like the first six rows above were peer/path issues, not agent issues. |
sendBufferMaxBytesper profile (64/128/256 KB)IMMEDIATE_ACK_THRESHOLD = 2(was 10)- Real
ack_delayreported in ACK frames (was always 0) - STOP_SENDING handler emits matching RESET_STREAM (was ignored)
- RESET_STREAM frame handled (forces EOF on recv buffer)
- Auto-RESET sweeper triggers on
lastDrainNanosidle > 2 s - Build-97 stall self-heal removed entirely — the bounded buffer + sweeper handles the same scenario without disconnects
- New stats line columns:
send_buf_queued,auto-resetevents vialogStat(appear in exportable agent.log)
Only HIGH_1000 was exercised in this series. LOW_100 and MID_500 should be run through the same QUIC matrix to confirm the auto-RESET 2 s timeout doesn't false-positive at lower target rates (where drain cadence is naturally slower).
- Code:
app/src/main/java/com/proxyagent/app/nativeagent/quic/NetworkProfile.kt - Architecture context: ARCHITECTURE.md §NetworkProfile-driven tuning
- Operator-facing docs: ADMIN_GUIDE.md §3.7 Network optimization
- BINARY engine non-support: BINARIES.md §4