From 05f83342a6d0477f464d2a5dad62e3d0d67cd631 Mon Sep 17 00:00:00 2001 From: sameermanvi Date: Tue, 3 Nov 2026 20:29:38 +0530 Subject: [PATCH] Guide protocol demos with scenario presets and measured recovery plots --- .gitignore | 1 + README.md | 4 +- docs/DEMO.md | 51 +- docs/REPORT.md | 9 + results/presentation/goodput_mbps.svg | 1917 +++++++++++++++++++++++++ results/presentation/timeline.svg | 128 ++ scripts/commands/plot_metrics.py | 19 +- scripts/commands/plot_timeline.py | 116 +- scripts/commands/visualize.py | 19 +- scripts/commands/viz_live.py | 23 +- scripts/commands/viz_replay.py | 10 +- scripts/commands/viz_scenarios.py | 53 + scripts/visualizer/app.js | 894 +++++++++--- scripts/visualizer/index.html | 379 +++-- scripts/visualizer/style.css | 340 ++++- src/client.c | 14 +- tests/test_visualize.py | 28 + 17 files changed, 3541 insertions(+), 464 deletions(-) create mode 100644 results/presentation/goodput_mbps.svg create mode 100644 results/presentation/timeline.svg create mode 100644 scripts/commands/viz_scenarios.py diff --git a/.gitignore b/.gitignore index 4bebab5..5434e6c 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ htmlcov/ # Local experiment outputs; retain the two verified bundles /results/* !/results/README.md +!/results/presentation/ !/results/rubric-final/ !/results/rubric-final-native/ *.part.?????? diff --git a/README.md b/README.md index 2225636..52531b4 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Run the console from the repository root. It builds the C binaries and opens the python3 scripts/run.py viz ``` -Choose transport flags and click **Run Live C Transfer**. The console captures actual +Choose one of the six numbered scenarios. The default explain-mode playback supports play, pause and single-event stepping. Advanced manual controls and raw logs are collapsed below the three main panels. The console captures actual 24-byte headers through a UDP relay, streams sender/receiver JSONL records, shows the sender ring snapshots, and reports independent SHA-256 verification. The relay adds 15 ms in each direction to make the transitions visible. **Drop next DATA** @@ -130,7 +130,7 @@ It provides no authentication, encryption, persistent resume or TCP fairness. ## Measured failover -![Lower-branch failure and application recovery](results/rubric-final/lower-recovery/link-failure-failover-adaptive-bitmap-off-loss0.0-fail1-flap1-lower-active-01/plots/timeline.svg) +![Lower-branch failure and application recovery](results/presentation/timeline.svg) The retained Linux trial shows the sequence of controller and application events. Across the measured evaluation, 132 transfers matched their hashes and 48 static diff --git a/docs/DEMO.md b/docs/DEMO.md index a2d4ba2..dc712d5 100644 --- a/docs/DEMO.md +++ b/docs/DEMO.md @@ -29,28 +29,29 @@ and static failures take longer than a ten-minute presentation, so rehearse and select the stages needed for the assessment. -## Live telemetry console - -Run the console from the repository root. It builds the C binaries and opens the browser. +## Guided browser demonstration ```sh python3 scripts/run.py viz ``` -Choose transport flags and click **Run Live C Transfer**. The console captures actual -24-byte headers through a UDP relay, streams sender/receiver JSONL records, shows -the sender ring snapshots, and reports independent SHA-256 verification. The relay -adds 15 ms in each direction to make the transitions visible. **Drop next DATA** -discards one observed DATA datagram. +Choose a numbered preset. Scenarios 1–3 execute real 1 MiB C transfers with the +displayed transport settings. Scenario 4 runs the no-SACK and SACK variants on +identical bytes and compares their measured outcomes. Scenarios 5–6 automatically +load verified upper-path link-cut recordings, preferring live-failover/live-static +directories when available and falling back to the retained evaluation. -Load a trial from the selector or enter a directory under results/ and select -**Tail active trial** for a running Mininet experiment. Replay preserves recorded -monotonic timing with play, pause, single-step and speed controls. Older recordings -supply only the fields they retained. The localhost run supplies transport data, -while Mininet recordings supply controller events. +Play, pause and single-event stepping affect visual presentation. The backend +continues collecting the real run into an SSE journal. Slow Explain Mode samples +key transitions at ten events per second and pauses briefly on major milestones. +The counters follow the displayed event, and measured backend timestamps remain +independent of presentation delays. Duplicate DATA arrivals are reported separately +from final file integrity. +Raw logs, wire bytes, manual transport settings and active-trial tail controls are +inside the collapsed advanced drawer. Pause a red gap, amber buffered chunk or +route change and read the scenario's three talking points. -Run the localhost examples from the repository root in a Linux shell. Each receiver exits after one upload. ## Transfer under loss @@ -93,3 +94,25 @@ find "$run_dir" -name timeline.svg ``` Open the failover trial’s timeline.svg in a browser. Point to link down, alternate route ready and the first ACK that advances the contiguous file prefix. The output hash must match. In static mode the route stays on the failed branch and the transfer fails as expected. Prepare the static run before the presentation to avoid waiting for retry exhaustion. + +## Four-person ten-minute presentation + +Use this as an ownership and rehearsal plan. Attribute implementation work to its actual authors. + +| Presenter | Time | Scenario | What to say and point to | +| --- | --- | --- | --- | +| 1 — Sender and reliability | 0:00–2:00 | 1, then 2 | “UDP gives us datagrams. Our sender adds sequence numbers, a bounded 32-slot window and retransmission. A missing ACK holds the contiguous base.” Point to a missing slot and the timer. | +| 2 — Receiver and integrity | 2:00–4:00 | 3 | “The receiver saves later chunks instead of discarding them. SACK reports which chunks are already buffered. Once the hole is repaired, the contiguous prefix advances. CRC32 checks the file before finalization and independent SHA-256 verifies it.” Point to amber slots, the bitmap and MATCH. | +| 3 — SDN control | 4:00–7:00 | 5 | “The application cannot repair a broken path. OpenFlow port status detects the hard cut. The controller installs the lower branch and waits for barriers. Separate bidirectional probes detect soft degradation and assess the backup.” Pause on LINK DOWN and ROUTE READY. | +| 4 — Integration and evidence | 7:00–9:00 | 6, then 4 | “A static route cannot deliver through a permanent cut. The recorded matrix contains 132 hash-matched transfers and 48 expected static failures. This A/B pair is a separate live comparison.” Point to the failure outcome and measured comparison bars. | +| All — Questions | 9:00–10:00 | Pause | Each person answers for the module they reviewed. Every member should be able to trace START → DATA/ACK → FIN and link down → route ready → useful ACK. | + +Prepare the recorded traces and A/B results before the assessment so selection and +playback fit the time limit. Scenario 4 runs actual transfers and can take longer +under random loss. Nobody should promise a particular speedup or recovery time. + +For the graphs, explain the labelled measurement interval before quoting a number. +The recovery title gives link-down to the first useful ACK after route readiness. +The separate timestamp rows resolve markers that overlap at full-run scale. +The congestion trace is the project's UDP congestion window. It does not measure +TCP fairness or establish the behavior of a TCP implementation. diff --git a/docs/REPORT.md b/docs/REPORT.md index 874d803..dc3ab5a 100644 --- a/docs/REPORT.md +++ b/docs/REPORT.md @@ -75,3 +75,12 @@ from retained digest records and endpoint completion, not a fresh payload rehash [Matrix](../results/rubric-final/matrix/metrics.csv), [lower recovery](../results/rubric-final/lower-recovery/metrics.csv), [robustness](../results/rubric-final/robustness/metrics.csv). + +## Presentation plots + +The [labelled recovery timeline](../results/presentation/timeline.svg) is regenerated +from the retained lower-recovery trial's progress, controller and outage records. +The [goodput comparison](../results/presentation/goodput_mbps.svg) is regenerated +from matrix/metrics.csv. Multi-line configuration labels, units, sample counts and +mean/range annotations make each group identifiable. These are new renderings of +the recorded measurements. diff --git a/results/presentation/goodput_mbps.svg b/results/presentation/goodput_mbps.svg new file mode 100644 index 0000000..c993238 --- /dev/null +++ b/results/presentation/goodput_mbps.svg @@ -0,0 +1,1917 @@ + + + + + + + + 2026-11-03T20:24:49.091300 + image/svg+xml + + + Matplotlib v3.11.2, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + + 2 + + + + + + + + + + + + + 4 + + + + + + + + + + + + + 6 + + + + + + + + + + + + + 8 + + + + Payload goodput (Mb/s) + + + + + + + + + + + + + + normal | static / fixed / plain / cc=off + loss=0 · fail=1 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + normal | static / fixed / bitmap / cc=off + loss=0 · fail=1 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + normal | static / adaptive / plain / cc=off + loss=0 · fail=1 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + normal | static / adaptive / bitmap / cc=off + loss=0 · fail=1 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + normal | failover / fixed / plain / cc=off + loss=0 · fail=1 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + normal | failover / fixed / bitmap / cc=off + loss=0 · fail=1 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + normal | failover / adaptive / plain / cc=off + loss=0 · fail=1 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + normal | failover / adaptive / bitmap / cc=off + loss=0 · fail=1 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + loss | static / fixed / plain / cc=off + loss=0.0 · fail=1 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + loss | static / fixed / plain / cc=off + loss=2.0 · fail=1 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + loss | static / fixed / bitmap / cc=off + loss=0.0 · fail=1 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + loss | static / fixed / bitmap / cc=off + loss=2.0 · fail=1 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + loss | static / adaptive / plain / cc=off + loss=0.0 · fail=1 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + loss | static / adaptive / plain / cc=off + loss=2.0 · fail=1 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + loss | static / adaptive / bitmap / cc=off + loss=0.0 · fail=1 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + loss | static / adaptive / bitmap / cc=off + loss=2.0 · fail=1 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + loss | failover / fixed / plain / cc=off + loss=0.0 · fail=1 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + loss | failover / fixed / plain / cc=off + loss=2.0 · fail=1 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + loss | failover / fixed / bitmap / cc=off + loss=0.0 · fail=1 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + loss | failover / fixed / bitmap / cc=off + loss=2.0 · fail=1 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + loss | failover / adaptive / plain / cc=off + loss=0.0 · fail=1 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + loss | failover / adaptive / plain / cc=off + loss=2.0 · fail=1 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + loss | failover / adaptive / bitmap / cc=off + loss=0.0 · fail=1 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + loss | failover / adaptive / bitmap / cc=off + loss=2.0 · fail=1 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + link-failure | static / fixed / plain / cc=off + loss=0.0 · fail=0.5 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + link-failure | static / fixed / plain / cc=off + loss=0.0 · fail=1.5 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + link-failure | static / fixed / plain / cc=off + loss=2.0 · fail=0.5 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + link-failure | static / fixed / plain / cc=off + loss=2.0 · fail=1.5 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + link-failure | static / fixed / bitmap / cc=off + loss=0.0 · fail=0.5 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + link-failure | static / fixed / bitmap / cc=off + loss=0.0 · fail=1.5 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + link-failure | static / fixed / bitmap / cc=off + loss=2.0 · fail=0.5 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + link-failure | static / fixed / bitmap / cc=off + loss=2.0 · fail=1.5 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + link-failure | static / adaptive / plain / cc=off + loss=0.0 · fail=0.5 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + link-failure | static / adaptive / plain / cc=off + loss=0.0 · fail=1.5 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + link-failure | static / adaptive / plain / cc=off + loss=2.0 · fail=0.5 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + link-failure | static / adaptive / plain / cc=off + loss=2.0 · fail=1.5 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + link-failure | static / adaptive / bitmap / cc=off + loss=0.0 · fail=0.5 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + link-failure | static / adaptive / bitmap / cc=off + loss=0.0 · fail=1.5 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + link-failure | static / adaptive / bitmap / cc=off + loss=2.0 · fail=0.5 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + link-failure | static / adaptive / bitmap / cc=off + loss=2.0 · fail=1.5 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + link-failure | failover / fixed / plain / cc=off + loss=0.0 · fail=0.5 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + link-failure | failover / fixed / plain / cc=off + loss=0.0 · fail=1.5 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + link-failure | failover / fixed / plain / cc=off + loss=2.0 · fail=0.5 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + link-failure | failover / fixed / plain / cc=off + loss=2.0 · fail=1.5 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + link-failure | failover / fixed / bitmap / cc=off + loss=0.0 · fail=0.5 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + link-failure | failover / fixed / bitmap / cc=off + loss=0.0 · fail=1.5 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + link-failure | failover / fixed / bitmap / cc=off + loss=2.0 · fail=0.5 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + link-failure | failover / fixed / bitmap / cc=off + loss=2.0 · fail=1.5 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + link-failure | failover / adaptive / plain / cc=off + loss=0.0 · fail=0.5 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + link-failure | failover / adaptive / plain / cc=off + loss=0.0 · fail=1.5 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + link-failure | failover / adaptive / plain / cc=off + loss=2.0 · fail=0.5 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + link-failure | failover / adaptive / plain / cc=off + loss=2.0 · fail=1.5 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + link-failure | failover / adaptive / bitmap / cc=off + loss=0.0 · fail=0.5 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + link-failure | failover / adaptive / bitmap / cc=off + loss=0.0 · fail=1.5 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + link-failure | failover / adaptive / bitmap / cc=off + loss=2.0 · fail=0.5 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + link-failure | failover / adaptive / bitmap / cc=off + loss=2.0 · fail=1.5 · flap=1 · start=upper + cut=upper · reorder=0 · degrade=0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 9.296 (n=3) + + + 9.310 (n=3) + + + 9.308 (n=3) + + + 9.298 (n=3) + + + 9.306 (n=3) + + + 9.300 (n=3) + + + 9.299 (n=3) + + + 9.289 (n=3) + + + 9.306 (n=3) + + + 3.580 (n=3) + + + 9.310 (n=3) + + + 4.962 (n=3) + + + 9.311 (n=3) + + + 4.785 (n=3) + + + 9.284 (n=3) + + + 5.914 (n=3) + + + 9.279 (n=3) + + + 3.301 (n=3) + + + 9.293 (n=3) + + + 5.108 (n=3) + + + 9.289 (n=3) + + + 4.902 (n=3) + + + 9.291 (n=3) + + + 6.014 (n=3) + + + N/A + + + N/A + + + N/A + + + N/A + + + N/A + + + N/A + + + N/A + + + N/A + + + N/A + + + N/A + + + N/A + + + N/A + + + N/A + + + N/A + + + N/A + + + N/A + + + 8.975 (n=3) + + + 9.060 (n=3) + + + 3.261 (n=3) + + + 3.365 (n=3) + + + 9.057 (n=3) + + + 9.060 (n=3) + + + 5.017 (n=3) + + + 4.964 (n=3) + + + 9.242 (n=3) + + + 9.244 (n=3) + + + 4.778 (n=3) + + + 4.881 (n=3) + + + 9.246 (n=3) + + + 9.247 (n=3) + + + 5.997 (n=3) + + + 6.007 (n=3) + + + Goodput of successful transfers (mean and min–max; N/A is not zero) + + + + + + + + + diff --git a/results/presentation/timeline.svg b/results/presentation/timeline.svg new file mode 100644 index 0000000..ba4e185 --- /dev/null +++ b/results/presentation/timeline.svg @@ -0,0 +1,128 @@ + + + +Reliable UDP recovery across an OpenFlow route change +Shared monotonic clock · application measurements · time in seconds from first recorded event +Observed recovery 42.265 ms +Link down → barrier-confirmed route 1.227 ms · route → useful ACK 41.038 ms +1 LINK DOWN t = 1.331889 s +2 ROUTE READY t = 1.333116 s +3 USEFUL ACK t = 1.374154 s +Contiguous acknowledged file bytes + +0.0 + +4,194,304.0 + +8,388,608.0 + + + + + +0.00 +2.86 +5.71 +8.57 +11.42 +14.28 +Elapsed monotonic time (s) +UDP retransmission timeout (ms) + +0.0 + +50.0 + +100.0 + + + + + +0.00 +2.86 +5.71 +8.57 +11.42 +14.28 +Elapsed monotonic time (s) +UDP congestion window (packets) + +0.0 + +16.0 + +32.0 + + + + + +0.00 +2.86 +5.71 +8.57 +11.42 +14.28 +Elapsed monotonic time (s) +Retransmitted DATA sequence number + +0.0 + +598.0 + +1,196.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +0.00 +2.86 +5.71 +8.57 +11.42 +14.28 +Elapsed monotonic time (s) +Markers may share an x-position at full-run scale; the timestamp rows above retain exact ordering and intervals. +Blue lines: measured application state · retry dots: amber fast gap / red timeout · congestion window belongs to this UDP implementation. + \ No newline at end of file diff --git a/scripts/commands/plot_metrics.py b/scripts/commands/plot_metrics.py index 163409b..570a9a0 100644 --- a/scripts/commands/plot_metrics.py +++ b/scripts/commands/plot_metrics.py @@ -48,7 +48,11 @@ def make_plots(summaries, output): plt.rcParams.update({"font.size": 10, "axes.spines.top": False, "axes.spines.right": False, "svg.fonttype": "none"}) - labels = [f"{item['scenario']} | {item['mode']}" for item in summaries] + labels = [] + for item in summaries: + parts = item['mode'].split('/') + labels.append(item['scenario'] + ' | ' + ' / '.join(parts[:4]) + '\n' + + ' · '.join(parts[4:8]) + '\n' + ' · '.join(parts[8:])) colors = ["#2563eb" if item["mode"].startswith("failover") else "#64748b" for item in summaries] for metric, xlabel, title in ( ("goodput_mbps", "Payload goodput (Mb/s)", "Goodput of successful transfers"), @@ -57,7 +61,7 @@ def make_plots(summaries, output): ("progress_recovery_seconds", "Seconds after failure request", "Application recovery from injection"), ("link_down_to_progress_seconds", "Seconds after controller link_down", "Application recovery after observed failure"), ): - fig, ax = plt.subplots(figsize=(14, max(4, len(summaries) * .35))) + fig, ax = plt.subplots(figsize=(16, max(5, len(summaries) * .8))) for index, item in enumerate(summaries): measurement = item[metric] if measurement: @@ -65,19 +69,22 @@ def make_plots(summaries, output): ax.barh(index, mean, color=colors[index], height=.65) ax.errorbar(mean, index, xerr=[[mean-measurement["minimum"]], [measurement["maximum"]-mean]], color="#111827", capsize=3, fmt="none") + ax.annotate(f" {mean:.3f} (n={measurement['count']})", + (measurement['maximum'], index), xytext=(5, 0), + textcoords="offset points", va="center", fontsize=8) else: ax.text(.01, index, "N/A", transform=ax.get_yaxis_transform(), va="center") - ax.set_yticks(range(len(labels)), labels, fontsize=7) + ax.set_yticks(range(len(labels)), labels, fontsize=8) ax.invert_yaxis(); ax.set_xlabel(xlabel) ax.set_title(title + " (mean and min–max; N/A is not zero)") - ax.set_xlim(left=0); ax.grid(axis="x", alpha=.2); ax.set_axisbelow(True) + ax.set_xlim(left=0); ax.margins(x=.25); ax.grid(axis="x", alpha=.2); ax.set_axisbelow(True) fig.tight_layout(); fig.savefig(output / f"{metric}.svg"); plt.close(fig) - fig, ax = plt.subplots(figsize=(14, max(4, len(summaries) * .35))) + fig, ax = plt.subplots(figsize=(16, max(5, len(summaries) * .8))) rates = [100 * item["successful"] / item["observations"] for item in summaries] ax.barh(range(len(labels)), rates, color=colors) for index, item in enumerate(summaries): ax.text(rates[index]+1, index, f"{item['successful']}/{item['observations']}", va="center") - ax.set_yticks(range(len(labels)), labels, fontsize=7); ax.invert_yaxis() + ax.set_yticks(range(len(labels)), labels, fontsize=8); ax.invert_yaxis() ax.set_xlim(0, 115); ax.set_xlabel("Successful flows (%)") ax.set_title("Completion across all trials, including failed runs") fig.tight_layout(); fig.savefig(output / "completion.svg"); plt.close(fig) diff --git a/scripts/commands/plot_timeline.py b/scripts/commands/plot_timeline.py index abd4435..5a48451 100644 --- a/scripts/commands/plot_timeline.py +++ b/scripts/commands/plot_timeline.py @@ -47,53 +47,87 @@ def bar_plot(rows, output, metric, title): def timeline(progress, controller, output): app = events(progress) network = events(controller) if controller else [] - all_events = app + network - start = min(e['monotonic_ns'] for e in all_events) - duration = max((e['monotonic_ns'] - start) / 1e9 for e in all_events) or 1 - width, height = 1100, 820 - lines = svg_start(width, height, 'Application recovery and SDN control on one monotonic clock') + combined = app + network + if not combined: + raise ValueError("No events to plot") + start = min(e['monotonic_ns'] for e in combined) + duration = max((e['monotonic_ns']-start)/1e9 for e in combined) or 1 + lines = svg_start(1200, 1120, "Reliable UDP recovery across an OpenFlow route change") + lines.append('Shared monotonic clock · application measurements · time in seconds from first recorded event') + advances = sorted((e for e in app if e['event']=='ack_progress'), key=lambda e:e['monotonic_ns']) + observations = [] + outage_file = progress.parent/'outages-0.json' + outages = json.loads(outage_file.read_text()) if outage_file.exists() else [] + for route in network: + if route.get('event')!='route_ready' or route.get('cause') not in ('link_down','switch_down'): + continue + previous_route = max((e['monotonic_ns'] for e in network + if e['event']=='route_ready' and e['monotonic_ns']route['monotonic_ns'] and e['base']>baseline),None) + if useful: + observations.append((cut,route,useful)) + markers = [] + if observations: + cut,route,useful = observations[0] + total = (useful['monotonic_ns']-cut['monotonic_ns'])/1e6 + install = (route['monotonic_ns']-cut['monotonic_ns'])/1e6 + progress_ms = (useful['monotonic_ns']-route['monotonic_ns'])/1e6 + lines.append(f'Observed recovery {total:.3f} ms') + lines.append(f'Link down → barrier-confirmed route {install:.3f} ms · route → useful ACK {progress_ms:.3f} ms') + markers = [(cut,'1 LINK DOWN','#dc2626'), (route,'2 ROUTE READY','#15803d'),(useful,'3 USEFUL ACK','#0369a1')] + else: + lines.append('Recovery N/A — no complete link-down → route-ready → useful-ACK sequence') + markers = [(e,'LINK DOWN','#dc2626') for e in network if e['event']=='link_down'][:3] + for i,(event,label,color) in enumerate(markers): + when=(event['monotonic_ns']-start)/1e9 + lines.append(f'{escape(label)} t = {when:.6f} s') def x(stamp): - return 100 + 950 * ((stamp - start) / 1e9) / duration - panels = [('ack_progress', 'bytes', 'Contiguous acknowledged bytes'), - ('rto_update', 'value', 'RTO (ms)'), ('cwnd_change', 'value', 'Congestion window (packets)'), - ('retransmit', 'sequence', 'Retransmitted sequence')] - for panel, (kind, field, title) in enumerate(panels): - top = 80 + panel * 180 - selected = [e for e in app if e['event'] == kind] - maximum = max((e[field] for e in selected), default=1) or 1 - lines.append(f'{title}; max={maximum:.1f}') - lines.append(f'') - points = [(x(e['monotonic_ns']), top + 125 - 120 * e[field] / maximum) for e in selected] - if kind != 'retransmit': - coordinates = ' '.join(f'{px:.2f},{py:.2f}' for px, py in points) - lines.append(f'') + return 125 + 1000*((stamp-start)/1e9)/duration + panels = [('ack_progress','bytes','Contiguous acknowledged file bytes'), + ('rto_update','value','UDP retransmission timeout (ms)'), + ('cwnd_change','value','UDP congestion window (packets)'), + ('retransmit','sequence','Retransmitted DATA sequence number')] + for index,(kind,field,title) in enumerate(panels): + top=240+index*195 + selected=[e for e in app if e['event']==kind] + maximum=max((e[field] for e in selected),default=1) or 1 + lines.append(f'{escape(title)}') + for tick in range(3): + value=maximum*tick/2 + py=top+120-120*tick/2 + lines.append(f'') + lines.append(f'{value:,.1f}') + lines.append(f'') + points=[(x(e['monotonic_ns']),top+120-120*e[field]/maximum) for e in selected] + if not selected: + lines.append(f'No recorded samples') + elif kind!='retransmit': + coords=' '.join(f'{px:.2f},{py:.2f}' for px,py in points) + lines.append(f'') else: - for e, (px, py) in zip(selected, points): - color = '#ef4444' if e['reason'] == 'timeout' else '#d97706' + for e,(px,py) in zip(selected,points): + color='#dc2626' if e.get('reason')=='timeout' else '#d97706' lines.append(f'') - for e in network: - if e['event'] in ('link_down', 'route_ready'): - px = x(e['monotonic_ns']) - color = '#dc2626' if e['event'] == 'link_down' else '#16a34a' - lines.append(f'') - if panel == 0: - advances = [e for e in app if e['event'] == 'ack_progress'] - for route in network: - if route.get('event') != 'route_ready' or route.get('cause') not in ('link_down', 'switch_down'): - continue - baseline = max((e['base'] for e in advances if e['monotonic_ns'] <= route['monotonic_ns']), default=0) - useful = next((e for e in advances if e['monotonic_ns'] > route['monotonic_ns'] and e['base'] > baseline), None) - if useful: - px = x(useful['monotonic_ns']) - py = top+125-120*useful['bytes']/maximum - lines.append(f'') - lines.append(f'first useful ACK after route_ready') + for event,label,color in markers: + px=x(event['monotonic_ns']) + lines.append(f'') for tick in range(6): - lines.append(f'{duration*tick/5:.2f}s') - lines.append('Blue: application state. Red dashed: link_down. Green dashed: route_ready. Orange dots: fast retry; red dots: timeout.') + lines.append(f'{duration*tick/5:.2f}') + lines.append(f'Elapsed monotonic time (s)') + lines.append('Markers may share an x-position at full-run scale; the timestamp rows above retain exact ordering and intervals.') + lines.append('Blue lines: measured application state · retry dots: amber fast gap / red timeout · congestion window belongs to this UDP implementation.') + lines.append('') output.write_text('\n'.join(lines)) - def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--metrics', type=Path) diff --git a/scripts/commands/visualize.py b/scripts/commands/visualize.py index e4d5e84..1c7a044 100644 --- a/scripts/commands/visualize.py +++ b/scripts/commands/visualize.py @@ -2,6 +2,7 @@ import argparse import json import math +import os from pathlib import Path import subprocess import sys @@ -16,6 +17,7 @@ ASSETS = ROOT / "scripts/visualizer" sys.path.insert(0, str(Path(__file__).resolve().parent)) from viz_live import run_live +from viz_scenarios import run_scenario from viz_replay import Playback, list_trials, resolve_trial, run_replay @@ -63,7 +65,9 @@ def start(self, mode, options, trial=None): def run(): try: - if mode == 'live': + if mode == 'scenario': + run_scenario(options['scenario'], self.results, self.emit, self.cancel, self.loss) + elif mode == 'live': run_live(options, self.emit, self.cancel, self.loss) else: run_replay(trial, mode == 'follow', self.emit, self.cancel, self.playback) @@ -193,6 +197,14 @@ def do_POST(self): def control(self, path, body): session = self.server.session + if path == '/api/scenario': + number = body.get('scenario') + if type(number) is not int or not 1 <= number <= 6: + raise ValueError('Select a scenario from 1 to 6') + session.cancel.set() + if session.worker: + session.worker.join(timeout=10) + return session.start('scenario', dict(scenario=number)) if path == '/api/start': return session.start('live', live_options(body)) if path == '/api/replay-trial': @@ -213,7 +225,7 @@ def control(self, path, body): raise Busy('No active session') if path == '/api/stop': session.cancel.set() - elif path == '/api/inject-loss' and session.mode == 'live': + elif path == '/api/inject-loss' and session.mode in ('live', 'scenario'): session.loss.set() elif path == '/api/playback' and session.playback: session.playback.control(body.get('action'), body.get('speed')) @@ -234,7 +246,8 @@ def main(): server.session = Session(ROOT/'results') url = f'http://127.0.0.1:{server.server_port}/' print(f'Telemetry console at {url}', flush=True) - if not args.no_browser: + if not args.no_browser and (sys.platform in ('darwin', 'win32') + or os.environ.get('DISPLAY') or os.environ.get('WAYLAND_DISPLAY')): webbrowser.open(url) try: server.serve_forever() diff --git a/scripts/commands/viz_live.py b/scripts/commands/viz_live.py index 644eccf..98a1d71 100644 --- a/scripts/commands/viz_live.py +++ b/scripts/commands/viz_live.py @@ -15,26 +15,28 @@ ROOT = Path(__file__).resolve().parents[2] -def run_live(options, emit, stop_event, loss_control): +def run_live(options, emit, stop_event, loss_control, payload=None): def publish(event, **fields): emit(dict(event=event, source='relay', monotonic_ns=time.monotonic_ns(), **fields)) with tempfile.TemporaryDirectory(prefix='rudp-viz-') as folder: folder = Path(folder) source, output = folder/'input.bin', folder/'output.bin' - source.write_bytes(os.urandom(256*1024)) + source.write_bytes(payload if payload is not None else os.urandom(1024*1024)) reserve = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) reserve.bind(('127.0.0.1', 0)) port = reserve.getsockname()[1] reserve.close() relay = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) relay.bind(('127.0.0.1', 0)) - logs, handles, processes = {}, [], [] + loss_path = folder/'drops.jsonl' + loss_path.touch() + logs, handles, processes = {'loss': loss_path.open()}, [], [] try: for role in ('receiver', 'sender'): path = folder/(role+'.jsonl') logs[role] = None handles.append((folder/(role+'.log')).open('w+')) - env = dict(os.environ, RUDP_PROGRESS_LOG=str(path), RUDP_VIZ_RING="1") + env = dict(os.environ, RUDP_PROGRESS_LOG=str(path), RUDP_VIZ_RING="1", RUDP_VIZ_DROP_LOG=str(loss_path)) if role == 'receiver': command = [str(ROOT/'server'), str(port), str(output)] else: @@ -114,10 +116,19 @@ def publish(event, **fields): original = source.read_bytes() received = output.read_bytes() if output.exists() else b'' match = output.exists() and hashlib.sha256(original).digest() == hashlib.sha256(received).digest() - publish('verification', sha256_match=match, source_sha256=hashlib.sha256(original).hexdigest(), + def fields(name): + import re + return dict(re.findall(r'(\w+)=([^\s]+)', (folder/name).read_text())) + sent, accepted = fields('sender.log'), fields('receiver.log') + expected_crc = f'{zlib.crc32(original):08x}' + crc_match = sent.get('crc32') == accepted.get('crc32') == expected_crc + publish('verification', sha256_match=match, crc32_match=crc_match, file_bytes=len(original), + seconds=float(sent.get('seconds', 0)), goodput_mbps=float(sent.get('goodput_mbps', 0)), + data_sends=int(sent.get('data_sends', 0)), retransmits=int(sent.get('retransmits', 0)), + duplicates=int(accepted.get('duplicates', 0)), out_of_order=int(accepted.get('out_of_order', 0)), source_sha256=hashlib.sha256(original).hexdigest(), output_sha256=hashlib.sha256(received).hexdigest() if output.exists() else None, crc32=f'{zlib.crc32(original):08x}', sender_exit=codes[1], receiver_exit=codes[0]) - if codes != [0, 0] or not match: + if codes != [0, 0] or not match or not crc_match: raise RuntimeError('Transfer failed: ' + (folder/'sender.log').read_text() + (folder/'receiver.log').read_text()) finally: for process in processes: diff --git a/scripts/commands/viz_replay.py b/scripts/commands/viz_replay.py index 755d82d..09e568b 100644 --- a/scripts/commands/viz_replay.py +++ b/scripts/commands/viz_replay.py @@ -66,6 +66,10 @@ def run_replay(trial, follow, emit, stop_event, playback): offsets = {} previous = None link_down = route_ready = None + outage_file = trial/'outages-0.json' + outages = json.loads(outage_file.read_text()) if outage_file.exists() else [] + measured_acks = {o.get('useful_ack_monotonic_ns'): o for o in outages + if o.get('recovery_status') == 'recovered'} while not stop_event.is_set(): if follow and not (trial/'controller.jsonl').exists(): candidates = sorted(trial.rglob('controller.jsonl')) @@ -92,12 +96,16 @@ def run_replay(trial, follow, emit, stop_event, playback): if not playback.wait(0 if follow else gap, stop_event): return emit(record) - if record.get('event') == 'link_down': + if record.get('event') == 'link_down' and link_down is None: link_down, route_ready = stamp, None elif record.get('event') == 'route_ready' and link_down is not None and stamp > link_down: route_ready = stamp elif (record.get('event') == 'ack_progress' and route_ready is not None and stamp > route_ready): + measured = measured_acks.get(stamp) + if measured: + link_down = measured['link_down_monotonic_ns'] + route_ready = measured['route_ready_monotonic_ns'] emit(dict(event='recovery', source='replay', monotonic_ns=stamp, link_down_to_ack_ms=(stamp-link_down)/1e6, route_to_ack_ms=(stamp-route_ready)/1e6)) diff --git a/scripts/commands/viz_scenarios.py b/scripts/commands/viz_scenarios.py new file mode 100644 index 0000000..60db714 --- /dev/null +++ b/scripts/commands/viz_scenarios.py @@ -0,0 +1,53 @@ +"""Guided presets choose real transport options or verified recorded SDN trials.""" +import json +import os +from viz_live import run_live +from viz_replay import run_replay + +def options(number): + basic = number == 2 + return dict(sack=not basic, adaptive_rto=not basic, cc=not basic, + drop_rate=0 if number == 1 else .10) + +def select_trial(root, number): + mode = 'failover' if number == 5 else 'static' + preferred = root/('live-failover' if number == 5 else 'live-static') + candidates = sorted(preferred.rglob('metrics.json')) if preferred.exists() else [] + candidates += sorted((root/'rubric-final').rglob('metrics.json')) + for path in candidates: + path.resolve().relative_to(root.resolve()) + data = json.loads(path.read_text()) + rows = data if isinstance(data, list) else [data] + if not rows: + continue + row = rows[0] + if (row.get('scenario') == 'link-failure' and row.get('mode') == mode + and row.get('initial_path') == 'upper' + and row.get('expectation_met') is True + and (row.get('sha256_match') is True if number == 5 else row.get('success') is False) + and (path.parent/'controller.jsonl').exists()): + return path.parent, row + raise ValueError('No verified '+mode+' link-cut trial found under results/') + +class Unpaced: + """The browser owns visual timing while this producer preserves record timestamps.""" + def wait(self, seconds, stop): + return not stop.is_set() + +def run_scenario(number, root, emit, stop, loss): + if number in (1,2,3): + run_live(options(number), emit, stop, loss) + elif number == 4: + payload = os.urandom(1024*1024) + for variant in (2,3): + if stop.is_set(): + return + emit(dict(event='variant_start', source='session', variant=variant, **options(variant))) + def tagged(record): + emit(dict(record, variant=variant)) + run_live(options(variant), tagged, stop, loss, payload=payload) + else: + trial, metrics = select_trial(root, number) + emit(dict(event='trial_metadata', source='session', file_bytes=metrics['file_bytes'], + trial='results/'+trial.relative_to(root).as_posix(), metrics=metrics)) + run_replay(trial, False, emit, stop, Unpaced()) diff --git a/scripts/visualizer/app.js b/scripts/visualizer/app.js index b0f9228..6baa2bd 100644 --- a/scripts/visualizer/app.js +++ b/scripts/visualizer/app.js @@ -1,256 +1,774 @@ "use strict"; const $ = (id) => document.getElementById(id); -let origin = null, - rows = [], - frozen = false, - ring = [], +const names = [ + "", + "Baseline UDP", + "Loss without SACK", + "Loss with SACK", + "A/B comparison", + "SDN failover", + "Static route failure", +]; +const talking = { + 1: [ + "START negotiates file metadata and the ACK format before DATA flows.", + "Selective Repeat bounds the sender to 32 slots; contiguous ACKs advance the base.", + "FIN and CRC32 establish completion. Independent SHA-256 verifies the reconstructed file.", + ], + 2: [ + "This run disables SACK, adaptive RTO and congestion control.", + "Missing DATA blocks the contiguous window until retransmission fills the hole.", + "The receiver still buffers later packets. Plain ACK mode also supports gap-based retransmission.", + ], + 3: [ + "The receiver buffers out-of-order DATA and reports a cumulative base plus a 32-bit bitmap.", + "Three distinct later-ACK reports can trigger a selective fast retry. A timeout remains the fallback.", + "Karn’s rule avoids ambiguous RTT samples; RTO and cwnd respond to measured feedback.", + ], + 4: [ + "Both variants send the same 1 MiB payload, with independent 10% loss draws.", + "Compare measured duration, goodput and extra sends. A single pair does not establish statistical superiority.", + "Duplicate DATA arrivals are distinct from duplicate deliveries. The finalized file must match.", + ], + 5: [ + "The red marker is the recorded controller port-down event on the active branch.", + "Green route readiness follows OpenFlow barriers. The next useful ACK demonstrates application progress.", + "The displayed recovery interval comes from event timestamps in this selected recorded trial.", + ], + 6: [ + "Static mode keeps host traffic on its original path after the link cut.", + "Application retries cannot repair a permanently disconnected network route.", + "An expected failure is a successful experiment expectation, while the file transfer remains unsuccessful.", + ], +}; +let backendDone = false; +let queue = [], + raw = [], + paused = false, + slow = true, + nextAt = 0, + origin = null, + scenario = 0; +let ring = [], + base = 0, + route = "upper", + broken = new Set(), + activeAnimation = null; +let seenMilestones = new Set(), + sampled = {}, + firstRetry = null, + retryCount = 0; +let producer = {}, + results = {}, + latest = {}, series = [], - rto = null, - cwnd = null, - srtt = null, - startBytes = null, - topology = {}, - goodput = "—"; -async function post(path, body = {}) { - try { - const r = await fetch(path, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - const data = await r.json(); - if (!r.ok) throw Error(data.error); - $("notice").textContent = "Control accepted"; - } catch (e) { - $("notice").textContent = e.message; - } + changing = false; +function blank() { + return { + sent: 0, + drops: 0, + retries: 0, + ooo: 0, + bytes: 0, + rto: null, + cwnd: null, + total: 1048576, + }; } -function reset() { +let view = blank(); +const say = (text) => { + $("live-narration").textContent = text; +}; +async function request(path, body = {}) { + const response = await fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const data = await response.json(); + if (!response.ok) throw Error(data.error); + return data; +} +function error(exc) { + $("notice").textContent = exc.message; +} +function reset(event) { + backendDone = false; + queue = []; + raw = []; origin = null; - rows = []; - series = []; ring = []; - topology = {}; - rto = cwnd = srtt = startBytes = null; - goodput = "—"; - $("events").replaceChildren(); - $("header").replaceChildren(); - $("frame").textContent = "No captured header in this session"; - $("mask").textContent = "SACK mask —"; + base = 0; + route = "upper"; + broken.clear(); + seenMilestones.clear(); + sampled = {}; + firstRetry = null; + retryCount = 0; + producer = {}; + results = {}; + latest = {}; + series = []; + view = blank(); + paused = false; + nextAt = 0; + scenario = event.scenario || 0; + $("status").textContent = "RECEIVING REAL DATA"; + $("comparison").hidden = scenario !== 4; $("verify").textContent = "Verification pending"; - $("sdn").textContent = "Waiting for controller events"; - for (const p of ["upper", "lower"]) $(p).setAttribute("stroke", "#586069"); + $("frame").textContent = "No captured wire header in this session."; + $("header").replaceChildren(); + $("mask").textContent = "SACK register —"; + $("network-mode").textContent = + scenario >= 5 + ? "Recorded Mininet / OpenFlow 1.3 trial" + : "Localhost C transfer / observed UDP relay. Diamond is a schematic in this mode."; + $("talking").replaceChildren( + ...(talking[scenario] || talking[1]).map((text) => { + const li = document.createElement("li"); + li.textContent = text; + return li; + }), + ); + for (const button of document.querySelectorAll("[data-scenario]")) { + button.classList.toggle( + "active", + Number(button.dataset.scenario) === scenario, + ); + button.setAttribute( + "aria-pressed", + String(Number(button.dataset.scenario) === scenario), + ); + } + $("upper").setAttribute("stroke", scenario >= 5 ? "#3fb950" : "#586069"); + $("lower").setAttribute("stroke", "#586069"); + $("route-status").textContent = + scenario >= 5 + ? "Waiting for recorded route state" + : "Localhost observation"; renderRing(); + renderCounters(); draw(); } +for (const button of document.querySelectorAll("[data-scenario]")) + button.onclick = async () => { + if (changing) return; + changing = true; + try { + $("notice").textContent = + "Preparing the selected real run or recorded trace…"; + await request("/api/scenario", { + scenario: Number(button.dataset.scenario), + }); + } catch (exc) { + error(exc); + } finally { + changing = false; + } + }; +$("play").onclick = () => { + paused = false; + nextAt = 0; + if (activeAnimation) activeAnimation.play(); +}; +$("pause").onclick = () => { + paused = true; + if (activeAnimation) activeAnimation.pause(); +}; +$("step").onclick = () => { + paused = true; + if (queue.length) present(queue.shift()); +}; +$("speed").onchange = () => { + slow = $("speed").value === "slow"; + nextAt = 0; +}; +$("stop").onclick = async () => { + queue = []; + paused = true; + try { + if (!backendDone) await request("/api/stop"); + } catch (exc) { + error(exc); + } + $("status").textContent = "STOPPED"; +}; $("run").onclick = () => - post("/api/start", { + request("/api/start", { sack: $("sack").checked, adaptive_rto: $("adaptive").checked, cc: $("cc").checked, drop_rate: Number($("loss").value), - }); -$("inject").onclick = () => post("/api/inject-loss"); -$("stop").onclick = () => post("/api/stop"); + }).catch(error); +$("inject").onclick = () => request("/api/inject-loss").catch(error); $("load").onclick = () => - post("/api/replay-trial", { + request("/api/replay-trial", { trial: $("trialPath").value || $("trials").value, follow: $("follow").checked, - speed: Number($("speed").value), - }); -for (const name of ["play", "pause", "step"]) - $(name).onclick = () => post("/api/playback", { action: name }); -$("speed").onchange = () => - post("/api/playback", { action: "speed", speed: Number($("speed").value) }); -$("freeze").onclick = () => { - frozen = !frozen; - $("freeze").textContent = frozen ? "Resume table" : "Pause table"; - if (!frozen) renderRows(); -}; -$("filter").oninput = renderRows; + speed: 1, + }).catch(error); fetch("/api/trials") .then((r) => r.json()) .then((d) => $("trials").replaceChildren(...d.trials.map((t) => new Option(t, t))), ) - .catch((e) => ($("notice").textContent = e.message)); + .catch(error); function renderRing() { + $("window-label").textContent = + "base " + + base + + " … " + + (base + 31) + + " | DELIVERED IN ORDER < " + + base + + (scenario >= 5 ? " | event-derived recording" : " | sender state"); $("ring").replaceChildren( ...Array.from({ length: 32 }, (_, i) => { - const s = ring[i], - d = document.createElement("div"); - d.className = "slot " + (s?.state || ""); - d.textContent = s - ? s.sequence + "\n" + s.state + "\ngap " + s.gap_reports + "/3" - : "—"; - return d; + const slot = ring[i], + div = document.createElement("div"); + div.className = "slot " + (slot?.state || ""); + const num = document.createElement("b"); + num.textContent = slot?.sequence ?? base + i; + div.append(num); + div.append( + document.createTextNode( + slot + ? { + UNSENT: "UNSENT", + IN_FLIGHT: "IN FLIGHT", + SACKED_OO_BUFFERED: "BUFFERED", + DROPPED_GAP: "MISSING", + FAST_RETRANSMIT: "FAST RETRY", + ACKED_CONTIGUOUS: "DELIVERED", + }[slot.state] || slot.state + : "—", + ), + ); + if (slot?.gap_reports) + div.append( + document.createElement("br"), + document.createTextNode("gap " + slot.gap_reports + "/3"), + ); + return div; }), ); } -renderRing(); -function renderRows() { - const f = $("filter").value.toLowerCase(); - $("events").replaceChildren( - ...rows - .filter((e) => - ((e.source || "") + " " + e.event).toLowerCase().includes(f), - ) - .slice(-200) - .reverse() - .map((e) => { - const tr = document.createElement("tr"); - const t = - e.monotonic_ns && origin !== null - ? ((e.monotonic_ns - origin) / 1e9).toFixed(3) - : "—"; - for (const value of [t, e.source, e.event, JSON.stringify(e)]) { - const td = document.createElement("td"); - td.textContent = value ?? "—"; - tr.append(td); - } - return tr; - }), +function renderCounters() { + $("progress").textContent = + Math.min(100, (view.bytes / view.total) * 100).toFixed(1) + "%"; + $("sent").textContent = view.sent; + $("retry").textContent = view.drops + " / " + view.retries; + $("buffered").textContent = view.ooo; + $("transport").textContent = + (view.rto === null ? "—" : view.rto.toFixed(0) + " ms") + + " / " + + (view.cwnd === null ? "—" : view.cwnd.toFixed(1)); +} +function keyEvent(e) { + if ( + [ + "session_end", + "variant_start", + "trial_metadata", + "verification", + "recorded_metrics", + "recovery", + "link_down", + "route_ready", + "dropped_gap", + ].includes(e.event) + ) + return true; + if (e.event === "retransmit") { + firstRetry ??= e.sequence; + retryCount++; + return ( + e.sequence === firstRetry || retryCount < 25 || retryCount % 16 === 0 + ); + } + if (e.event === "frame") + return ( + (e.wire_type !== 3 && e.wire_type !== 4 && e.wire_type !== 10) || + e.sequence < 8 || + e.sequence % 64 === 0 + ); + if (["send", "out_of_order", "gap_detected", "hole_filled"].includes(e.event)) + return e.sequence < 8 || e.sequence % 64 === 0; + if ( + [ + "ring_snapshot", + "ack_progress", + "path_health", + "rto_update", + "cwnd_change", + ].includes(e.event) + ) { + const bucket = Math.floor((e.monotonic_ns || 0) / 200000000); + if (sampled[e.event] === bucket) return false; + sampled[e.event] = bucket; + return true; + } + return false; +} +function ingest(e) { + if (e.event === "session_start") { + reset(e); + say( + "Preparing " + + (names[scenario] || "manual telemetry") + + ". Every displayed transition is backed by a recorded event.", + ); + return; + } + if (e.event === "session_end") backendDone = true; + raw.push(e); + if (raw.length > 1000) raw.shift(); + if (e.monotonic_ns && origin === null) origin = e.monotonic_ns; + if (e.event === "variant_start") { + producer = blank(); + sampled = {}; + firstRetry = null; + retryCount = 0; + } + producer = { ...blank(), ...producer }; + if (e.event === "send") producer.sent++; + if (e.event === "retransmit") { + producer.sent++; + producer.retries++; + } + if (e.event === "dropped_gap") producer.drops++; + if (e.event === "out_of_order") producer.ooo++; + if ( + ["rto_update", "send", "retransmit"].includes(e.event) && + e.value !== undefined + ) + producer.rto = e.value; + if (e.event === "cwnd_change") producer.cwnd = e.value; + if (e.event === "ack_progress") producer.bytes = e.bytes; + if (e.event === "trial_metadata") producer.total = e.file_bytes; + e.view = { ...producer }; + if (keyEvent(e)) queue.push(e); +} +const stream = new EventSource("/api/stream"); +stream.onmessage = (m) => ingest(JSON.parse(m.data)); +stream.onerror = () => + ($("notice").textContent = + "Connection interrupted. Waiting for the local backend to reconnect."); +function packet(label, red = false, reverse = false) { + if (activeAnimation) activeAnimation.cancel(); + const points = + route === "lower" + ? [ + [65, 110], + [200, 110], + [380, 175], + [560, 110], + [695, 110], + ] + : [ + [65, 110], + [200, 110], + [380, 45], + [560, 110], + [695, 110], + ]; + if (reverse) points.reverse(); + if (red) points.splice(3); + $("packet-label").textContent = label + (red ? " ×" : ""); + $("packet").style.visibility = "visible"; + $("packet").querySelector("rect").style.stroke = red ? "#f85149" : "#58a6ff"; + activeAnimation = $("packet").animate( + points.map(([x, y]) => ({ + transform: "translate(" + x + "px," + y + "px)", + })), + { duration: slow ? 650 : 180, fill: "forwards" }, ); } -function header(e) { +function frame(e) { $("header").replaceChildren( ...Array.from({ length: 24 }, (_, i) => { const d = document.createElement("div"); d.className = "byte"; - const n = document.createElement("small"); - n.textContent = i; + const s = document.createElement("small"); + s.textContent = i; d.append( - n, + s, document.createTextNode(e.header_hex.slice(i * 2, i * 2 + 2)), ); return d; }), ); - $("frame").textContent = JSON.stringify( - { - magic: e.magic, - version: e.version, - wire_type: e.wire_type, - flags: e.flags, - flag_names: [ - e.flags & 1 ? "SACK_CAPABLE" : null, - e.flags & 2 ? "SACK_BITMAP_PRESENT" : null, - e.flags & 4 ? "CONTROL_REPLY" : null, - ].filter(Boolean), - transfer_id: e.transfer_id, - sequence: e.sequence, - length: e.length, - crc32: e.crc32, - }, - null, - 2, - ); + $("frame").textContent = JSON.stringify(e, null, 2); if (e.sack_mask !== undefined) $("mask").textContent = - "SACK mask 0x" + + "SACK 0x" + e.sack_mask.toString(16).padStart(8, "0") + - " · receiver base " + + " | base " + e.sack_base; } +function mark(sequence, state) { + const i = sequence - base; + if (i >= 0 && i < 32) + ring[i] = { sequence, state, gap_reports: ring[i]?.gap_reports || 0 }; +} +function compare() { + const a = results[2], + b = results[3]; + if (!a || !b) return; + $("bars").replaceChildren(); + for (const [key, label, unit] of [ + ["seconds", "Total time", "s"], + ["goodput_mbps", "Payload goodput", "Mb/s"], + ["retransmits", "Retransmissions", "DATA"], + ["duplicates", "Duplicate DATA arrivals", "packets"], + ]) { + const row = document.createElement("div"); + row.className = "measure"; + const title = document.createElement("span"); + title.textContent = label; + row.append(title); + const max = Math.max(a[key], b[key], 1); + for (const [data, cls, name] of [ + [a, "", "Basic"], + [b, "sack", "SACK"], + ]) { + const cell = document.createElement("div"); + cell.textContent = name + " " + data[key] + " " + unit; + const bar = document.createElement("div"); + bar.className = "bar " + cls; + bar.style.width = (data[key] / max) * 100 + "%"; + cell.append(bar); + row.append(cell); + } + $("bars").append(row); + } + const note = document.createElement("p"); + note.textContent = + "Both files verified against identical source bytes. Matching file hashes confirm correct reconstruction; duplicate DATA arrivals are counted separately."; + $("bars").append(note); +} +function present(e) { + view = { ...blank(), ...(e.view || view) }; + const seq = e.sequence; + if (e.event === "variant_start") { + ring = []; + base = 0; + $("integrity").textContent = "PENDING"; + say( + "A/B run " + + (e.variant === 2 + ? "A — no SACK, fixed timer" + : "B — SACK, adaptive RTO and CC") + + ". The source file is identical in both runs.", + ); + } + if (e.event === "trial_metadata") { + $("notice").textContent = + "Recorded trial: " + + e.trial + + ". Measured times refer to backend monotonic timestamps."; + view.total = e.file_bytes; + } + if (e.event === "frame") { + frame(e); + const kind = e.wire_type; + if (kind === 1 || kind === 8) { + say( + "STEP 1 — HANDSHAKE: Client sent START with a 24-byte header and 12-byte file metadata" + + (kind === 8 ? " and SACK_CAPABLE." : ".") + + " Waiting for the server reply.", + ); + packet("START"); + } else if (kind === 2 || kind === 9) { + say( + "HANDSHAKE COMPLETE: The receiver acknowledged START" + + (kind === 9 ? " and accepted SACK." : ".") + + " DATA can now be pipelined.", + ); + packet("START_ACK", false, true); + } else if (kind === 5 || kind === 6) { + say( + "COMPLETION HANDSHAKE: FIN confirms the advertised file is complete and CRC-checked before the receiver finalizes its output.", + ); + packet(kind === 5 ? "FIN" : "FIN_ACK", false, e.direction === "ack"); + } else + packet( + kind === 10 ? "SACK" : kind === 4 ? "ACK" : "DATA #" + seq, + false, + e.direction === "ack", + ); + } + if ( + e.source === "sender" && + e.base !== undefined && + e.event !== "ring_snapshot" && + e.base !== base + ) { + base = e.base; + ring = Array.from( + { length: 32 }, + (_, i) => + ring.find((s) => s?.sequence === base + i) || { + sequence: base + i, + state: "UNSENT", + gap_reports: 0, + }, + ); + } + if (e.event === "ring_snapshot") { + base = e.base; + ring = e.slots.map((s) => ({ ...s })); + } + if (e.event === "send") { + mark(seq, "IN_FLIGHT"); + packet("DATA #" + seq); + say( + "PIPELINED DATA #" + + seq + + ": The sender retains this chunk until ACK coverage frees the slot. Current contiguous base is " + + base + + ".", + ); + } + if (e.event === "dropped_gap") { + mark(seq, "DROPPED_GAP"); + packet("DROP #" + seq, true); + say( + "OBSERVED PACKET DROP #" + + seq + + ": " + + (e.reason === "operator_injection" + ? "The operator dropped this DATA datagram." + : "The sender’s configured random-loss simulation discarded this DATA attempt.") + + " Later chunks may arrive while this gap holds the window.", + ); + } + if (e.event === "out_of_order") { + mark(seq, "SACKED_OO_BUFFERED"); + say( + "OUT-OF-ORDER #" + + seq + + ": The receiver buffered this chunk beyond its missing prefix. " + + (scenario === 2 + ? "Plain ACK mode also retains buffered chunks." + : "In SACK mode a bitmap reports buffered coverage to the sender."), + ); + } + if (e.event === "gap_detected") mark(e.base, "DROPPED_GAP"); + if (e.event === "retransmit") { + const fast = e.reason === "fast_gap"; + mark(seq, fast ? "FAST_RETRANSMIT" : "IN_FLIGHT"); + packet((fast ? "FAST RETRY #" : "TIMEOUT #") + seq, broken.has(route)); + say( + (fast + ? "FAST RETRANSMIT #" + + seq + + ": Three distinct later-ACK reports triggered a selective retry before the timer." + : "TIMEOUT RETRY #" + + seq + + ": The ACK deadline expired, so the sender retries this missing chunk.") + + (scenario === 6 + ? " The static route still points at the cut link." + : ""), + ); + } + if (e.event === "link_down") { + const path = + (e.dpid === 1 && e.port === 3) || e.dpid === 3 ? "lower" : "upper"; + broken.add(path); + $(path).setAttribute("stroke", "#f85149"); + $("route-status").textContent = "LINK DOWN " + path; + say( + "LINK FAILURE — " + + path.toUpperCase() + + ": Recorded OFPPortStatus reports a down port. " + + (scenario === 6 + ? "Static routing keeps the original route." + : "The controller must install a working alternate route."), + ); + } + if (e.event === "route_ready") { + route = e.path; + $(route).setAttribute("stroke", broken.has(route) ? "#f85149" : "#3fb950"); + $("route-status").textContent = + "ROUTE READY " + route + " — barrier confirmed"; + say( + "ROUTE READY — " + + route.toUpperCase() + + ": OpenFlow barriers confirmed rule processing. Continued ACK progress will prove application recovery.", + ); + } + if (e.source === "controller") { + latest[e.event] = e; + $("sdn").textContent = JSON.stringify(latest, null, 2); + } + if (e.event === "recovery") { + say( + "APPLICATION RECOVERED: First useful ACK after route readiness. Recorded link-down → useful ACK interval " + + e.link_down_to_ack_ms.toFixed(3) + + " ms. Playback pauses do not affect this measurement.", + ); + $("route-status").textContent = + "RECOVERED " + e.link_down_to_ack_ms.toFixed(3) + " ms"; + } + if (e.event === "ack_progress" && scenario < 5 && scenario !== 2) + say( + "CONTIGUOUS PROGRESS: " + + e.bytes.toLocaleString() + + " bytes acknowledged. The sender advances past completed chunks and reuses its bounded ring.", + ); + if (e.event === "verification") { + $("verify").textContent = JSON.stringify(e, null, 2); + view.bytes = e.file_bytes; + view.total = e.file_bytes; + view.sent = e.data_sends; + view.retries = e.retransmits; + view.ooo = e.out_of_order; + $("integrity").textContent = + e.sha256_match && e.crc32_match ? "MATCH" : "FAILED"; + say( + "TRANSFER VERIFIED: " + + e.file_bytes.toLocaleString() + + " bytes reconstructed. Sender and receiver CRC32 " + + (e.crc32_match ? "match" : "differ") + + "; independent SHA-256 " + + (e.sha256_match ? "matches." : "differs."), + ); + if (e.variant) { + results[e.variant] = e; + compare(); + } + } + if (e.event === "recorded_metrics") { + const data = Array.isArray(e.metrics) ? e.metrics[0] : e.metrics; + if (data) { + $("verify").textContent = JSON.stringify(data, null, 2); + view.sent = data.data_sends; + view.retries = data.retransmits; + view.ooo = data.out_of_order ?? view.ooo; + if (data.success && data.sha256_match) { + view.bytes = data.file_bytes; + view.total = data.file_bytes; + $("integrity").textContent = "MATCH"; + say( + "RECORDED TRANSFER VERIFIED: " + + data.file_bytes.toLocaleString() + + " bytes completed with matching SHA-256 digests. The recorded latency is " + + (data.link_down_to_progress_seconds * 1000).toFixed(3) + + " ms.", + ); + } else { + $("integrity").textContent = "NO OUTPUT"; + say( + "EXPECTED STATIC FAILURE: The transfer exhausted its bounded retries on a disconnected route. The file was not completed. The recorded experiment expectation was " + + (data.expectation_met ? "met." : "not met."), + ); + } + } + } + if (e.event === "session_end") { + $("status").textContent = e.ok + ? "PLAYBACK COMPLETE" + : e.cancelled + ? "STOPPED" + : "FAILED"; + if (e.error) { + say("Run failed: " + e.error); + $("notice").textContent = e.error; + } + } + if (e.monotonic_ns && view.rto !== null) { + series.push({ + time: (e.monotonic_ns - origin) / 1e9, + rto: view.rto, + cwnd: view.cwnd, + }); + if (series.length > 2000) series.shift(); + } + renderRing(); + renderCounters(); + draw(); +} function draw() { const c = $("chart"), g = c.getContext("2d"); g.clearRect(0, 0, c.width, c.height); g.strokeStyle = "#30363d"; - g.beginPath(); - for (let i = 0; i <= 4; i++) { - g.moveTo(0, i * 40 + 10); - g.lineTo(c.width, i * 40 + 10); + for (let i = 0; i < 5; i++) { + g.beginPath(); + g.moveTo(0, i * 35 + 10); + g.lineTo(c.width, i * 35 + 10); + g.stroke(); } - g.stroke(); for (const [key, max, color] of [ ["rto", 2000, "#58a6ff"], ["cwnd", 32, "#3fb950"], ]) { g.strokeStyle = color; g.beginPath(); - let begun = false; + let started = false; const lo = series[0]?.time || 0, span = Math.max(1, (series.at(-1)?.time || 0) - lo); for (const p of series) { if (p[key] === null) continue; const x = ((p.time - lo) / span) * c.width, - y = 170 - (p[key] / max) * 160; - begun ? g.lineTo(x, y) : g.moveTo(x, y); - begun = true; + y = 150 - (p[key] / max) * 140; + started ? g.lineTo(x, y) : g.moveTo(x, y); + started = true; } g.stroke(); } } -let dirty = false; +function renderRaw() { + if (!$("advanced").open) return; + const filter = $("filter").value.toLowerCase(); + $("events").replaceChildren( + ...raw + .filter((e) => (e.event + " " + e.source).toLowerCase().includes(filter)) + .slice(-120) + .reverse() + .map((e) => { + const tr = document.createElement("tr"); + for (const value of [ + e.monotonic_ns && origin !== null + ? ((e.monotonic_ns - origin) / 1e9).toFixed(3) + : "—", + e.source, + e.event, + JSON.stringify(e), + ]) { + const td = document.createElement("td"); + td.textContent = value; + tr.append(td); + } + return tr; + }), + ); +} setInterval(() => { - if (dirty) { - if (!frozen) renderRows(); - draw(); - dirty = false; - } -}, 150); -new EventSource("/api/stream").onmessage = (message) => { - const e = JSON.parse(message.data); - if (e.event === "session_start") { - reset(); - $("status").textContent = e.mode.toUpperCase(); - } - if (e.monotonic_ns && origin === null) origin = e.monotonic_ns; - rows.push(e); - if (rows.length > 1000) rows.shift(); - if (e.event === "frame") header(e); - if (e.event === "ring_snapshot") { - ring = e.slots; - renderRing(); - } - if (e.event === "dropped_gap") { - const s = ring.find((x) => x.sequence === e.sequence); - if (s) s.state = "DROPPED_GAP"; - renderRing(); - } - if (e.event === "rto_update") rto = e.value; - if (e.event === "srtt_update") srtt = e.value; - if (e.event === "cwnd_change") cwnd = e.value; - if (["rto_update", "cwnd_change"].includes(e.event)) { - series.push({ time: (e.monotonic_ns - origin) / 1e9, rto, cwnd }); - if (series.length > 2000) series.shift(); - } - if (e.event === "ack_progress") { - if (!startBytes) startBytes = e; - const elapsed = (e.monotonic_ns - startBytes.monotonic_ns) / 1e9; - if (elapsed > 0) - goodput = - (((e.bytes - startBytes.bytes) * 8) / elapsed / 1e6).toFixed(3) + - " Mb/s"; - } - $("metrics").textContent = - "SRTT " + - (srtt?.toFixed(1) ?? "—") + - " ms · RTO " + - (rto?.toFixed(1) ?? "—") + - " ms · cwnd " + - (cwnd?.toFixed(1) ?? "—") + - " · ACK goodput " + - goodput; - if (e.source === "controller") { - topology[e.event] = e; - $("sdn").textContent = JSON.stringify(topology, null, 2); - if (e.event === "route_ready") - for (const path of ["upper", "lower"]) - $(path).setAttribute("stroke", e.path === path ? "#3fb950" : "#586069"); - } - if (["verification", "recorded_metrics", "recovery"].includes(e.event)) - $("verify").textContent = JSON.stringify(e, null, 2); - if (e.event === "session_end") { - $("status").textContent = e.ok - ? "COMPLETE" - : e.cancelled - ? "STOPPED" - : "FAILED"; - if (e.error) $("notice").textContent = e.error; - } - dirty = true; -}; - -// Slots below the cumulative base have left the active ring. + $("position").textContent = + (paused ? "PAUSED · " : "") + "Key events queued " + queue.length; + if (paused || !queue.length || performance.now() < nextAt) return; + const e = queue.shift(); + present(e); + const milestone = + e.event === "retransmit" && e.reason === "fast_gap" + ? "fast_retry" + : e.event === "route_ready" + ? e.event + e.path + : e.event; + const important = [ + "dropped_gap", + "fast_retry", + "link_down", + "route_readyupper", + "route_readylower", + "verification", + ].includes(milestone); + const hold = slow && important && !seenMilestones.has(milestone); + if (hold) seenMilestones.add(milestone); + nextAt = performance.now() + (hold ? 1200 : slow ? 100 : 30); +}, 20); +setInterval(renderRaw, 500); +$("filter").oninput = renderRaw; +reset({}); diff --git a/scripts/visualizer/index.html b/scripts/visualizer/index.html index 5a7f058..2194f57 100644 --- a/scripts/visualizer/index.html +++ b/scripts/visualizer/index.html @@ -1,128 +1,261 @@ - RUDP protocol console -
RUDP / PROTOCOL CONSOLE IDLE
- - -

- Ready. Live mode observes C subprocesses through a 15 ms per-direction UDP - relay. -

-
-
-

01 / WIRE HEADER — 24 BYTES

- -
Waiting for a captured datagram.
- Byte 5 encodes logical type and flags together in wire version - 1. -
-
-

02 / SELECTIVE REPEAT — 32 SLOTS

-
SACK mask — · base —
-
- Sequences below base are ACKED_CONTIGUOUS. Exact sender snapshots in - live mode. Older recordings show available events. -
-
-

03 / OPENFLOW 1.3 DIAMOND

- - + + + + RUDP Guided Protocol Console + + + +
+
+ RUDP / GUIDED PROTOCOL CONSOLE +

+ Real C transfers · Recorded OpenFlow trials · Select a scenario to + begin +

+
+ READY +
+ +
+ Waiting for a scenario +
+
+ Choose scenario 1 to see a real file transfer, then compare loss recovery + or explore a recorded link cut. +
+

+ Explain mode samples key transitions at 10 events/s. Raw records remain + available below. Playback timing is separate from measured backend time. +

+
+
+

A / PACKET PATH

+

+ Local transfers use a localhost UDP relay. SDN topology activates for + recorded trials. +

+ + + + - - - - - h1 - s1 - s2 / Upper - s3 / Lower - s4 - h2 - - -
Load a Mininet trial for SDN telemetry.
-
-
-

04 / TRANSPORT TELEMETRY

-
Waiting for transport events
- Blue RTO (0–2000 ms) · green cwnd (0–32) -
Verification pending
-
-
-
-

- EVENT STREAM -

-
- - - - - - - - - - -
Time (s)SourceEventRaw JSON
-
-
- + + + + + + + + + + + h1 + s1 + s2 + s3 + s4 + h2 + Sender + Receiver + UPPER PATH + LOWER PATH + + + + + + +
Awaiting transfer
+
+
+

B / SELECTIVE REPEAT WINDOW

+
+ Delivered prefixIn flightBuffered / SACKedMissing gapFast retry +
+

32 bounded slots · waiting for backend state

+
+
SACK register —
+ Green prefix is below base and has left the active ring. Red requires + an observed drop or missing-base event. +
+
+

C / LIVE SCOREBOARD

+
+
+ FILE PROGRESS— +
+
+ DATA SEND ATTEMPTS— +
+
DROPS / RETRIES—
+
+ OUT-OF-ORDER SAVED— +
+
RTO / CWND—
+
+ SHA-256 INTEGRITYPENDING +
+
+
+

WHAT TO TELL THE EXAMINER

+
    +
    + +
    +
    +
    + Advanced Manual Controls & Raw Wire Logs + + +
    +

    WIRE HEADER / OFFSETS 0–23

    + +
    No captured frame.
    + Wire byte 5 encodes the logical type and flags together. +
    
    +        
    
    +      
    +
    +

    TRANSPORT TRACE

    + RTO blue (0–2000 ms) · cwnd green (0–32) · backend monotonic + time +
    +
    + +
    + + + + + + + + + + +
    TimeSourceEventRecorded fields
    +
    + The table retains the last 1,000 records. +
    +
    + + diff --git a/scripts/visualizer/style.css b/scripts/visualizer/style.css index b52bc5c..9a2fd9e 100644 --- a/scripts/visualizer/style.css +++ b/scripts/visualizer/style.css @@ -1,7 +1,7 @@ :root { color-scheme: dark; font: - 12px "JetBrains Mono", + 13px "JetBrains Mono", "Fira Code", monospace; font-variant-numeric: tabular-nums; @@ -12,158 +12,317 @@ box-sizing: border-box; } body { - margin: 0; + margin: 0 auto; + max-width: 1680px; + padding: 20px; } -header, -nav { +header { display: flex; + justify-content: space-between; align-items: center; - gap: 16px; - padding: 14px 20px; - border-bottom: 1px solid #30363d; - flex-wrap: wrap; + margin-bottom: 20px; } -header { - justify-content: space-between; - font-size: 15px; +header strong { + font-size: 18px; +} +p { + color: #8b949e; + line-height: 1.6; } button, input, select { font: inherit; + color: inherit; background: #0d1117; - color: #c9d1d9; border: 1px solid #30363d; - padding: 7px; + padding: 10px; } button { cursor: pointer; } -button:hover { - border-color: #8b949e; +button:hover, +button.active { + border-color: #58a6ff; + background: #161b22; } button:focus-visible, input:focus-visible, -select:focus-visible { +select:focus-visible, +summary:focus-visible { outline: 2px solid #58a6ff; } -input[type="number"] { - width: 75px; +button:disabled { + opacity: 0.5; + cursor: wait; } -#trials { - max-width: 300px; +#scenarios { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; } -#notice { - padding: 0 20px; +#scenarios button { + display: flex; + align-items: center; + gap: 16px; + text-align: left; + min-height: 88px; + padding: 16px; +} +#scenarios b { + font-size: 24px; + color: #58a6ff; +} +#scenarios small { + display: block; + margin-top: 8px; + color: #8b949e; + font-size: 11px; +} +.playbar { + display: flex; + gap: 12px; + align-items: center; + flex-wrap: wrap; + margin: 16px 0; +} +#position { color: #8b949e; + margin-left: auto; +} +#live-narration { + background: #161b22; + border: 1px solid #30363d; + border-left: 4px solid #58a6ff; + font-size: 17px; + line-height: 1.6; + padding: 20px; + min-height: 100px; +} +#notice { + font-size: 11px; + min-height: 32px; } main { display: grid; - grid-template-columns: 1fr 1fr; - gap: 12px; - padding: 0 16px 12px; + grid-template-columns: 1.1fr 1fr; + gap: 14px; } section { background: #161b22; border: 1px solid #30363d; - padding: 14px; + padding: 18px; min-width: 0; } -h2 { +h2, +h3 { font-size: 12px; - font-weight: 500; - margin: 0 0 15px; color: #8b949e; letter-spacing: 1px; + font-weight: 500; + margin-top: 0; } -small { - color: #8b949e; - line-height: 1.8; +svg { + width: 100%; + display: block; } -pre { - white-space: pre-wrap; - overflow-wrap: anywhere; - line-height: 1.6; - max-height: 220px; - overflow: auto; +svg text { + fill: #c9d1d9; + font-size: 13px; } -#header { - display: grid; - grid-template-columns: repeat(12, 1fr); - gap: 3px; +.nodes circle { + fill: #0d1117; + stroke: #8b949e; } -.byte { - border: 1px solid #30363d; - text-align: center; - padding: 6px; +#packet { + visibility: hidden; } -.byte small { - display: block; +#packet rect { + fill: #0d1117; + stroke: #58a6ff; +} +#route-status { + font-size: 14px; + padding: 12px; + border-top: 1px solid #30363d; +} +.legend { + display: flex; + flex-wrap: wrap; + gap: 12px; + font-size: 11px; +} +.green { + color: #3fb950; +} +.blue { + color: #58a6ff; +} +.amber { + color: #d29922; +} +.red { + color: #f85149; +} +.cyan { + color: #39c5cf; } #ring { display: grid; grid-template-columns: repeat(8, 1fr); - gap: 4px; - margin: 12px 0; + gap: 5px; + margin: 15px 0; } .slot { - white-space: pre-line; border: 1px solid #30363d; - padding: 7px 3px; - font-size: 9px; + min-height: 70px; text-align: center; - min-height: 57px; + padding: 9px 3px; + font-size: 11px; overflow-wrap: anywhere; } +.slot b { + display: block; + font-size: 16px; + margin-bottom: 7px; +} .IN_FLIGHT { border-color: #58a6ff; + color: #58a6ff; } .SACKED_OO_BUFFERED { - border-color: #3fb950; + border-color: #d29922; + color: #d29922; } -.FAST_RETRANSMIT, .DROPPED_GAP { - border-color: #d29922; + border-color: #f85149; + color: #f85149; } -svg, -canvas { - width: 100%; +.FAST_RETRANSMIT { + border-color: #39c5cf; + color: #39c5cf; +} +.ACKED_CONTIGUOUS { + border-color: #3fb950; + color: #3fb950; +} +small { + color: #8b949e; + line-height: 1.7; +} +.scoreboard { + grid-column: 1/-1; +} +.counters { + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 12px; +} +.counters > div { + border: 1px solid #30363d; + padding: 15px; +} +.counters strong { display: block; + margin-top: 14px; + font-size: 20px; } -canvas { - height: 180px; - margin: 8px 0; +.counters small { + font-size: 10px; +} +.talking { + margin-top: 20px; + border-top: 1px solid #30363d; + padding-top: 20px; } -.events { - margin: 0 16px 16px; +.talking li { + line-height: 1.8; +} +#bars .measure { + display: grid; + grid-template-columns: 180px 1fr 1fr; + gap: 15px; + align-items: center; + padding: 12px 0; + border-bottom: 1px solid #30363d; +} +.bar { + height: 7px; + margin-top: 8px; + background: #58a6ff; +} +.bar.sack { + background: #3fb950; +} +details { + margin-top: 20px; + border: 1px solid #30363d; } -.events h2 { +summary { + cursor: pointer; + padding: 18px; +} +details nav { display: flex; align-items: center; - gap: 12px; + gap: 10px; + flex-wrap: wrap; + padding: 15px; +} +details section { + border-width: 1px 0 0; +} +#trials { + max-width: 340px; +} +#loss { + width: 80px; +} +pre { + white-space: pre-wrap; + overflow-wrap: anywhere; + max-height: 220px; + overflow: auto; + line-height: 1.5; +} +#header { + display: grid; + grid-template-columns: repeat(24, 1fr); + gap: 3px; +} +.byte { + text-align: center; + border: 1px solid #30363d; + padding: 5px; +} +.byte small { + display: block; +} +canvas { + width: 100%; + height: 160px; } .scroll { height: 250px; overflow: auto; } table { - border-collapse: collapse; + table-layout: fixed; width: 100%; + border-collapse: collapse; text-align: left; - table-layout: fixed; } -th, -td { - padding: 7px; +td, +th { border-bottom: 1px solid #30363d; - vertical-align: top; + padding: 7px; overflow-wrap: anywhere; } th:nth-child(1) { - width: 85px; + width: 80px; } th:nth-child(2) { - width: 85px; + width: 90px; } th:nth-child(3) { width: 160px; @@ -173,14 +332,37 @@ thead { top: 0; background: #161b22; } -@media (max-width: 900px) { +@media (max-width: 1000px) { main { grid-template-columns: 1fr; } - nav { - gap: 8px; + .counters { + grid-template-columns: repeat(3, 1fr); + } + #scenarios { + grid-template-columns: repeat(2, 1fr); } #header { - grid-template-columns: repeat(8, 1fr); + grid-template-columns: repeat(12, 1fr); + } +} +@media (max-width: 600px) { + body { + padding: 10px; + } + #scenarios { + grid-template-columns: 1fr; + } + .counters { + grid-template-columns: repeat(2, 1fr); + } + #ring { + grid-template-columns: repeat(4, 1fr); + } + #bars .measure { + grid-template-columns: 1fr; + } + #position { + margin: 0; } } diff --git a/src/client.c b/src/client.c index c36fa00..6f3f452 100644 --- a/src/client.c +++ b/src/client.c @@ -86,7 +86,19 @@ static int send_data(int fd, const struct packet *packet, int multipath, unsigned int upper_weight, unsigned int lower_weight) { - if (drop_rate > 0 && rand() / (RAND_MAX + 1.0) < drop_rate) return 0; + if (drop_rate > 0 && rand() / (RAND_MAX + 1.0) < drop_rate) { + const char *path = getenv("RUDP_VIZ_DROP_LOG"); + if (path) { + FILE *trace = fopen(path, "a"); + if (trace) { + fprintf(trace, "{\"event\":\"dropped_gap\",\"monotonic_ns\":%" PRIu64 + ",\"sequence\":%u,\"reason\":\"simulated_data_loss\"}\n", + monotonic_ns(), packet->sequence); + fclose(trace); + } + } + return 0; + } if (!multipath) return send_packet(fd, packet, peer); diff --git a/tests/test_visualize.py b/tests/test_visualize.py index 77ded68..6b9b191 100644 --- a/tests/test_visualize.py +++ b/tests/test_visualize.py @@ -46,6 +46,34 @@ def test_live_http_and_integrity(self): thread.join() server.session.journal.close() + def test_guided_presets_select_real_static_and_failover_evidence(self): + from viz_scenarios import options, select_trial + self.assertEqual(options(1)['drop_rate'], 0) + self.assertFalse(options(2)['sack']) + self.assertFalse(options(2)['adaptive_rto']) + self.assertTrue(options(3)['sack']) + for number in (5,6): + trial, row = select_trial(ROOT/'results', number) + self.assertTrue((trial/'controller.jsonl').exists()) + self.assertTrue(row['expectation_met']) + self.assertEqual(row['success'], number==5) + + def test_recovery_plot_labels_match_retained_measurement(self): + import importlib.util + path = ROOT/'scripts/commands/plot_timeline.py' + spec = importlib.util.spec_from_file_location('plot_timeline', path) + plot = importlib.util.module_from_spec(spec) + spec.loader.exec_module(plot) + from viz_scenarios import select_trial + trial, row = select_trial(ROOT/'results', 5) + with tempfile.TemporaryDirectory() as folder: + output = Path(folder)/'timeline.svg' + plot.timeline(trial/'progress-0.jsonl', trial/'controller.jsonl', output) + text = output.read_text() + expected = f"{row['link_down_to_progress_seconds']*1000:.3f} ms" + self.assertIn(expected, text) + self.assertIn('Elapsed monotonic time (s)', text) + def test_replay_and_path_boundary(self): with tempfile.TemporaryDirectory() as folder: root = Path(folder)