awg-ctrl: keep CPU mode order in /api/metrics; diffbench compare and host runner

The differential bench (GEN 2 and 3.1, with I1, S4=0, upgrade with a 3.x
module) now shows the Go awg-ctrl identical to the TS one. The only real
difference it found: host.cpu was a Go map, so its keys came out sorted
instead of in /proc/stat order.

- tools/diffbench/compare.mjs: decodes vpn:// (Node and Go zlib differ), masks
  live host numbers, Express dev-mode stack traces, temp file names and the
  order of calls from concurrent background work
- tools/diffbench/local.sh: the same run without Docker, stubs only on the
  run's own PATH (for disposable machines)
- run.sh: wait for the background shaping apply before stopping the service,
  otherwise the stub log is cut short
This commit is contained in:
2026-10-14 13:27:24 +00:00
parent 5687ba0d3d
commit c2e9636f31
4 changed files with 73 additions and 5 deletions
+7 -5
View File
@@ -8,6 +8,8 @@ import (
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/maeneko/forgetting/internal/jsjson"
) )
// Метрики для Prometheus. awg-ctrl отдаёт JSON, а текстовый формат собирает awg-ui: на ноде // Метрики для Prometheus. awg-ctrl отдаёт JSON, а текстовый формат собирает awg-ui: на ноде
@@ -32,9 +34,9 @@ type metricsPeer struct {
} }
type metricsHost struct { type metricsHost struct {
CPU map[string]float64 `json:"cpu"` CPU jsjson.Object `json:"cpu"` // порядок режимов как в /proc/stat (и в Node-версии)
Cores int `json:"cores"` Cores int `json:"cores"`
Load []float64 `json:"load"` Load []float64 `json:"load"`
Mem struct { Mem struct {
Total float64 `json:"total"` Total float64 `json:"total"`
Available float64 `json:"available"` Available float64 `json:"available"`
@@ -89,13 +91,13 @@ func readHost() *metricsHost {
if len(first) < 2 { if len(first) < 2 {
return nil return nil
} }
h := &metricsHost{CPU: map[string]float64{}, Cores: runtime.NumCPU()} h := &metricsHost{Cores: runtime.NumCPU()}
for i, m := range cpuModes { for i, m := range cpuModes {
v := 0.0 v := 0.0
if i+1 < len(first) { if i+1 < len(first) {
v = orZero(jsNumber(first[i+1])) v = orZero(jsNumber(first[i+1]))
} }
h.CPU[m] = v / 100 // USER_HZ = 100 на всех обычных ядрах h.CPU = append(h.CPU, jsjson.KV{Key: m, Val: v / 100}) // USER_HZ = 100 на всех обычных ядрах
} }
mem, err := os.ReadFile("/proc/meminfo") mem, err := os.ReadFile("/proc/meminfo")
if err != nil { if err != nil {
+31
View File
@@ -0,0 +1,31 @@
// compare.mjs <ts.json> <go.json>: расхождения двух прогонов стенда. vpn:// раскрываются
// (сжатие у Node и Go разное), время маскируется.
import fs from "fs"; import zlib from "zlib";
const [a, b] = process.argv.slice(2).map(f => JSON.parse(fs.readFileSync(f, "utf8")));
const dec = s => { try { const buf = Buffer.from(s.slice(6), "base64url"); return "vpn<" + zlib.inflateSync(buf.subarray(4)).toString() + ">"; } catch (e) { return "vpn<undecodable " + e.message + ">"; } };
const norm = v => {
if (typeof v === "string") {
let s = v.replace(/vpn:\/\/[A-Za-z0-9_-]+/g, dec);
s = s.replace(/\d{4}-\d\d-\d\dT[\d:.-]+Z/g, "<ts>").replace(/"(created_at|lastHandshake|ts|time|uptime|generated_at)":\d+/g, '"$1":<n>');
return s;
}
if (Array.isArray(v)) return v.map(norm);
if (v && typeof v === "object") return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, /^(created_at|lastHandshake|ts|uptime)$/.test(k) && typeof x === "number" ? "<n>" : norm(x)]));
return v;
};
// Известный шум: живые цифры хоста (порядок ключей сохраняем), стек Express в dev-режиме,
// порядок вызовов из параллельных фоновых задач, отступы в CREATE TABLE.
const host = h => h && { cpu: Object.keys(h.cpu), cores: h.cores, load: h.load?.length, mem: Object.keys(h.mem) };
for (const r of [a, b]) {
for (const s of r.steps) {
if (s.body?.host) { s.body.host = host(s.body.host); s.text = JSON.stringify(s.body); }
if (s.ctype === "text/html") s.body = s.body.replace(/<pre>([A-Za-z]+Error)[^<]*(<br>[\s\S]*)?<\/pre>/, "<pre>$1</pre>");
}
r.state.stubLog = r.state.stubLog.map(e => JSON.stringify(e).replace(/awg_(sync|psk)_\d+/g, "awg_$1_N")).sort();
r.state.schema = r.state.schema.replace(/\s+/g, " ");
}
let diffs = 0;
const show = (label, x, y) => { const X = JSON.stringify(norm(x)), Y = JSON.stringify(norm(y)); if (X !== Y) { diffs++; let i = 0; while (X[i] === Y[i]) i++; const from = Math.max(0, i - 150); console.log(`### ${label} @${i}\n TS: …${X.slice(from, i + 350)}\n GO: …${Y.slice(from, i + 350)}`); } };
a.steps.forEach((s, i) => { const t = b.steps[i]; show(`${i} ${s.name}`, { st: s.status, ct: s.ctype, body: s.body, text: s.text }, { st: t.status, ct: t.ctype, body: t.body, text: t.text }); });
for (const k of Object.keys(a.state)) show("state." + k, a.state[k], b.state[k]);
console.log(diffs ? `${diffs} differences` : "IDENTICAL");
+33
View File
@@ -0,0 +1,33 @@
#!/bin/sh
# local.sh <ts|go> <имя-прогона> — тот же прогон, что run.sh, но без Docker: на одноразовой
# машине (CI-раннер, облачная песочница). Заглушки кладутся в свой каталог и попадают только
# в PATH этого прогона; ВНИМАНИЕ: setup.sh пересоздаёт /etc/amnezia — не запускать на сервере.
# Нужны node 20+, sqlite3, openssl, go; npm install в awg-ctrl/ делается при первом запуске.
# Окружение то же: GEN, WITH_I1, S4, STUB_MODULE. Результат — tools/diffbench/out/<имя>.json.
set -e
IMPL="$1"; NAME="$2"
B=$(cd "$(dirname "$0")" && pwd); ROOT=$(cd "$B/../.." && pwd)
mkdir -p "$B/bin/stubs" "$B/out"
for c in awg awg-quick ip tc nft modinfo; do
printf '#!/bin/sh\nexec node %s %s "$@"\n' "$B/stubs/stub.mjs" "$c" > "$B/bin/stubs/$c"; chmod +x "$B/bin/stubs/$c"
done
export PATH="$B/bin/stubs:$PATH"
if [ "$IMPL" = "go" ]; then (cd "$ROOT" && go build -o "$B/bin/awg-control" ./cmd/awg-control)
elif [ ! -d "$ROOT/awg-ctrl/node_modules" ]; then (cd "$ROOT/awg-ctrl" && npm install --no-audit --no-fund --loglevel=error --no-package-lock); fi
sh "$B/setup.sh"
export PORT=3005 SERVER_IP=203.0.113.7 SERVER_PORT=47619 SERVER_NAME=VPN \
INTERNAL_AUTH_PUB_FILE=/etc/amnezia/amneziawg/internal_auth_public.key \
SHAPE_SAMPLE_S=3600 AWG_CLI=/nonexistent/awg-ctrl
cd "$ROOT/awg-ctrl"
if [ "$IMPL" = "ts" ]; then node node_modules/.bin/tsx index.ts > /run/bench/ctrl.log 2>&1 &
else "$B/bin/awg-control" ctrl > /run/bench/ctrl.log 2>&1 &
fi
PID=$!
i=0; until curl -s -o /dev/null http://127.0.0.1:3005/health; do i=$((i+1)); [ $i -gt 150 ] && { cat /run/bench/ctrl.log; kill $PID; exit 1; }; sleep 0.1; done
sleep 1
cd "$B"
node scenario-ctrl.mjs "$B/out/$NAME.json"
sleep 3
kill $PID 2>/dev/null || true; wait $PID 2>/dev/null || true
node collect.mjs "$B/out/$NAME.json"
cp /run/bench/ctrl.log "$B/out/$NAME.log"
+2
View File
@@ -17,6 +17,8 @@ i=0; until curl -s -o /dev/null http://127.0.0.1:3005/health; do i=$((i+1)); [ $
sleep 1 # стартовый apply шейпинга асинхронный — даём ему отработать до сценария sleep 1 # стартовый apply шейпинга асинхронный — даём ему отработать до сценария
cd /bench cd /bench
node scenario-ctrl.mjs "/out/$NAME.json" node scenario-ctrl.mjs "/out/$NAME.json"
# фоновый apply шейпинга после restart/upgrade должен доработать, иначе журнал заглушек обрежется
sleep 3
kill $PID 2>/dev/null || true; wait $PID 2>/dev/null || true kill $PID 2>/dev/null || true; wait $PID 2>/dev/null || true
node collect.mjs "/out/$NAME.json" node collect.mjs "/out/$NAME.json"
cp /run/bench/ctrl.log "/out/$NAME.log" cp /run/bench/ctrl.log "/out/$NAME.log"