undefect. CWE-407 — 92 sites, 42 ecosystems
B&W print-friendly diagrams + tinkerpop-0001 + wave-3 proof sections. Squash of 94 local commits onto remote master.
This commit is contained in:
parent
0a580b313d
commit
db29a08762
1311 changed files with 371202 additions and 1188 deletions
40
tools/mc-bench/README.md
Normal file
40
tools/mc-bench/README.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# mc-bench — Minecraft Server Load Benchmark
|
||||
|
||||
Benchmarks vanilla vs patched Minecraft server-26.1 under simulated load.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
# JDK 25 required
|
||||
JAVA=/tmp/jdk25/bin/java
|
||||
# Server jar (bundler)
|
||||
SERVER_JAR=/home/fox/Downloads/server.jar
|
||||
# Install both servers
|
||||
bash bench-server.sh vanilla 40
|
||||
bash bench-server.sh patched 40
|
||||
```
|
||||
|
||||
## Tools
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `bench-server.sh` | Full benchmark: startup + `/reload` + bot wave |
|
||||
| `gen-modpack-tags.py` | Generates synthetic depth-N diamond tag data |
|
||||
| `raw-connect.mjs` | Raw TCP handshake bot (version-agnostic) |
|
||||
| `sim-players.mjs` | mineflayer bot runner (requires matching protocol version) |
|
||||
| `rcon.py` | RCON command injection |
|
||||
|
||||
## What it measures
|
||||
|
||||
- **Startup time** (wall clock, JVM launch → "Done")
|
||||
- **/reload time** (RCON-triggered, measures tag reload)
|
||||
- **Connection capacity** (how many handshakes the server accepts simultaneously)
|
||||
- **Handshake latency** (p50/p95/max)
|
||||
|
||||
## Notes
|
||||
|
||||
- Real-world improvement requires a genuine modpack with tag depth ≥ 10.
|
||||
- Synthetic tag data uses HashMap iteration (hash-ordered), which may not trigger
|
||||
worst-case DFS. Real modpacks load tags alphabetically (base before root) which
|
||||
reliably triggers the exponential in vanilla.
|
||||
- See `tests/bench/LoadSimBenchmark.java` for controlled algorithmic benchmark.
|
||||
205
tools/mc-bench/bench-server.sh
Executable file
205
tools/mc-bench/bench-server.sh
Executable file
|
|
@ -0,0 +1,205 @@
|
|||
#!/usr/bin/env bash
|
||||
# bench-server.sh — benchmark a Minecraft server: startup + /reload + bot load
|
||||
#
|
||||
# Usage: ./bench-server.sh <tier> [bot-count] [--play]
|
||||
#
|
||||
# Tiers:
|
||||
# unpatched control — vanilla jar, depth-16 datapack (defect present)
|
||||
# mitigated fix applied — patched jar, depth-16 datapack (same game, fast)
|
||||
# enriched new territory — patched jar, depth-24 300-ns datapack (impossible before patch)
|
||||
#
|
||||
# Aliases: vanilla=unpatched, patched=mitigated
|
||||
#
|
||||
# --play: after benchmarks, keep server up for human play testing.
|
||||
# Prints connection info and waits for Ctrl-C.
|
||||
set -euo pipefail
|
||||
export PYTHONUNBUFFERED=1
|
||||
|
||||
TIER="${1:-unpatched}"
|
||||
BOTS="${2:-20}"
|
||||
|
||||
# --play flag: accept anywhere in args
|
||||
PLAY_MODE=0
|
||||
for _arg in "$@"; do [ "$_arg" = "--play" ] && PLAY_MODE=1; done
|
||||
|
||||
# ── Tier configuration ────────────────────────────────────────────────────────
|
||||
case "$TIER" in
|
||||
unpatched|vanilla)
|
||||
TIER=unpatched
|
||||
DIR=/tmp/mc-bench/unpatched
|
||||
PORT=25565
|
||||
RCON=25575
|
||||
SERVER_JAR=/home/fox/Downloads/server.jar
|
||||
DATAPACK_DEPTH=16
|
||||
DATAPACK_NS=200
|
||||
DATAPACK_XREFS=6
|
||||
TIER_NOTE="control — DependencySorter defect present"
|
||||
;;
|
||||
mitigated|patched)
|
||||
TIER=mitigated
|
||||
DIR=/tmp/mc-bench/mitigated
|
||||
PORT=25566
|
||||
RCON=25576
|
||||
SERVER_JAR=/tmp/mc-bench/server-patched.jar
|
||||
DATAPACK_DEPTH=16
|
||||
DATAPACK_NS=200
|
||||
DATAPACK_XREFS=6
|
||||
TIER_NOTE="fix applied — same game, defect eliminated"
|
||||
;;
|
||||
enriched)
|
||||
DIR=/tmp/mc-bench/enriched
|
||||
PORT=25567
|
||||
RCON=25577
|
||||
SERVER_JAR=/tmp/mc-bench/server-patched.jar
|
||||
DATAPACK_DEPTH=48
|
||||
DATAPACK_NS=1000
|
||||
DATAPACK_XREFS=32
|
||||
TIER_NOTE="enriched — D=48 / 1000 NS / 32 xrefs: impossible before patch"
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 unpatched|mitigated|enriched [bot-count] [--play]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
JAVA=/tmp/jdk25/bin/java
|
||||
LOG="$DIR/bench.log"
|
||||
FIFO="$DIR/server.stdin"
|
||||
|
||||
pkill -f "port $PORT " 2>/dev/null || true
|
||||
sleep 1
|
||||
rm -f "$LOG" "$FIFO"
|
||||
rm -rf "$DIR/world"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
python3 "$SCRIPT_DIR/gen-modpack-tags.py" "$DIR" "$DATAPACK_DEPTH" "$DATAPACK_NS" "$DATAPACK_XREFS"
|
||||
|
||||
mkfifo "$FIFO"
|
||||
|
||||
echo "=========================================="
|
||||
echo " $TIER — Minecraft Server Benchmark"
|
||||
echo " $TIER_NOTE"
|
||||
echo "=========================================="
|
||||
echo " depth-${DATAPACK_DEPTH} ${DATAPACK_NS} namespaces ${DATAPACK_XREFS} xrefs · $BOTS bots"
|
||||
echo ""
|
||||
|
||||
# ── §1 Startup ────────────────────────────────────────────────────────────────
|
||||
echo "§1 Starting server..."
|
||||
START_MS=$(date +%s%3N)
|
||||
|
||||
exec 3<>"$FIFO"
|
||||
|
||||
(cd "$DIR" && exec $JAVA -Xmx3G -Xms512m \
|
||||
--enable-native-access=ALL-UNNAMED \
|
||||
-DbundlerRepoDir=. \
|
||||
-jar "$SERVER_JAR" \
|
||||
--nogui --port "$PORT" \
|
||||
<"$FIFO" >>"$LOG" 2>&1) &
|
||||
SERVER_PID=$!
|
||||
|
||||
for i in $(seq 1 180); do
|
||||
sleep 1
|
||||
if grep -q "Done (" "$LOG" 2>/dev/null; then
|
||||
STARTUP_MS=$(( $(date +%s%3N) - START_MS ))
|
||||
DONE_LINE=$(grep "Done (" "$LOG" | tail -1)
|
||||
echo " startup: ${STARTUP_MS} ms"
|
||||
echo " server: $DONE_LINE"
|
||||
break
|
||||
fi
|
||||
if ! kill -0 $SERVER_PID 2>/dev/null; then
|
||||
echo "ERROR: server exited prematurely"
|
||||
tail -12 "$LOG"
|
||||
exit 1
|
||||
fi
|
||||
[ $(( i % 10 )) -eq 0 ] && echo " ...${i}s"
|
||||
done
|
||||
|
||||
if ! grep -q "Done (" "$LOG" 2>/dev/null; then
|
||||
echo "ERROR: did not start in 180s"
|
||||
kill $SERVER_PID 2>/dev/null
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sleep 2 # let RCON bind
|
||||
|
||||
# ── §2 First bot wave ─────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "§2 First bot wave: $BOTS bots joining..."
|
||||
node "$SCRIPT_DIR/raw-connect.mjs" localhost "$PORT" "$BOTS" 2>&1
|
||||
sleep 2
|
||||
|
||||
# ── §3 /reload timing ─────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "§3 /reload timing..."
|
||||
|
||||
RELOAD_LINE=$(wc -l < "$LOG")
|
||||
RELOAD_START=$(date +%s%3N)
|
||||
|
||||
python3 "$SCRIPT_DIR/rcon.py" localhost "$RCON" benchpass "reload" 2>&1 || \
|
||||
{ echo "RCON failed, sending via stdin"; echo "reload" >&3; }
|
||||
|
||||
RELOAD_MS=""
|
||||
for i in $(seq 1 180); do
|
||||
sleep 1
|
||||
if tail -n +"$RELOAD_LINE" "$LOG" 2>/dev/null | grep -qE "Loaded [0-9]+ recipes"; then
|
||||
RELOAD_MS=$(( $(date +%s%3N) - RELOAD_START ))
|
||||
echo " /reload: ${RELOAD_MS} ms"
|
||||
tail -n +"$RELOAD_LINE" "$LOG" | grep -E "Loaded [0-9]+ (recipes|advancements)" | head -3 | while IFS= read -r l; do echo " $l"; done
|
||||
break
|
||||
fi
|
||||
done
|
||||
[ -z "$RELOAD_MS" ] && { echo " /reload: did not complete in 180s"; RELOAD_MS="-1"; }
|
||||
|
||||
# ── §4 Second bot wave (post-reload) ─────────────────────────────────────────
|
||||
echo ""
|
||||
echo "§4 Post-reload bot wave: $BOTS bots..."
|
||||
node "$SCRIPT_DIR/raw-connect.mjs" localhost "$PORT" "$BOTS" 2>&1
|
||||
|
||||
# ── §5 Play test mode (human) ─────────────────────────────────────────────────
|
||||
if [ "$PLAY_MODE" = "1" ]; then
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " PLAY TEST MODE — server staying up"
|
||||
echo "=========================================="
|
||||
echo " Connect to: localhost:$PORT"
|
||||
echo " Tier: $TIER ($TIER_NOTE)"
|
||||
echo " Log: $LOG"
|
||||
echo ""
|
||||
echo " Press Ctrl-C to stop the server and finish benchmark."
|
||||
echo ""
|
||||
# Wait for Ctrl-C; trap cleans up
|
||||
trap 'echo ""; echo "Stopping server..."; echo "stop" >&3; exec 3>&-; wait $SERVER_PID 2>/dev/null || true; rm -f "$FIFO"; echo "Server stopped."; exit 0' INT TERM
|
||||
while kill -0 $SERVER_PID 2>/dev/null; do sleep 5; done
|
||||
echo "Server exited on its own."
|
||||
exec 3>&- 2>/dev/null || true
|
||||
rm -f "$FIFO"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Stop ─────────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "Stopping server..."
|
||||
echo "stop" >&3
|
||||
exec 3>&-
|
||||
wait $SERVER_PID 2>/dev/null || true
|
||||
rm -f "$FIFO"
|
||||
|
||||
# ── Results ──────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " $TIER RESULTS"
|
||||
echo "=========================================="
|
||||
echo " startup: ${STARTUP_MS} ms"
|
||||
echo " reload: ${RELOAD_MS} ms"
|
||||
echo " bots: $BOTS"
|
||||
echo " depth: $DATAPACK_DEPTH ns: $DATAPACK_NS xrefs: $DATAPACK_XREFS"
|
||||
echo ""
|
||||
cat > "$DIR/results.txt" << EOF
|
||||
label=$TIER
|
||||
startup_ms=$STARTUP_MS
|
||||
reload_ms=$RELOAD_MS
|
||||
bots=$BOTS
|
||||
datapack_depth=$DATAPACK_DEPTH
|
||||
datapack_ns=$DATAPACK_NS
|
||||
datapack_xrefs=$DATAPACK_XREFS
|
||||
EOF
|
||||
202
tools/mc-bench/gen-modpack-tags.py
Normal file
202
tools/mc-bench/gen-modpack-tags.py
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
gen-modpack-tags.py — generates synthetic modpack tag data for Minecraft server benchmarking.
|
||||
|
||||
Design: reliably triggers the exponential DependencySorter.isCyclic defect in vanilla
|
||||
Minecraft server by exploiting the two-pass dependency-building algorithm.
|
||||
|
||||
HOW THE DEFECT TRIGGERS
|
||||
========================
|
||||
DependencySorter.orderByDependencies() runs two sequential passes over contents (HashMap):
|
||||
|
||||
Pass 1: contents.forEach((k, v) -> v.visitRequiredDependencies(
|
||||
dep -> addDependencyIfNotCyclic(multimap, k, dep)))
|
||||
Pass 2: contents.forEach((k, v) -> v.visitOptionalDependencies(
|
||||
dep -> addDependencyIfNotCyclic(multimap, k, dep)))
|
||||
|
||||
Pass 1 adds ALL required edges into `multimap` (for all namespaces).
|
||||
Pass 2 then processes optional edges — and by then the multimap is FULLY POPULATED.
|
||||
|
||||
addDependencyIfNotCyclic calls isCyclic(multimap, src, dst) which recurses through
|
||||
dst's subtree WITHOUT a visited set. On a diamond DAG of depth D, this is O(2^D).
|
||||
|
||||
GENERATOR STRATEGY
|
||||
==================
|
||||
Required deps: all internal diamond edges (bbb_tier → bbb_tier, bbb_tier_1 → aaa_base)
|
||||
Optional deps: root → top-tier edges (zzz_root → bbb_tier_D_left/right)
|
||||
cross-namespace mid-refs (zzz_root → foreign bbb_tier_mid)
|
||||
|
||||
Result: after Pass 1 every namespace's full diamond is in `multimap`. Then Pass 2
|
||||
triggers isCyclic on EVERY zzz_root's optional edges — worst-case for 100% of
|
||||
namespaces, not just the ~1/34 that happen to win the HashMap ordering lottery.
|
||||
|
||||
Expected visits per namespace at depth D:
|
||||
2 × isCyclic calls × 2^(D+1) nodes each ≈ 4 × 2^D
|
||||
|
||||
With N=200 namespaces at D=16:
|
||||
vanilla: 200 × 4 × 2^16 ≈ 52 M recursive calls (~5 s at 100 ns/call)
|
||||
patched: 200 × 4 × (1 visited-set lookup) ≈ negligible
|
||||
|
||||
Usage: python3 gen-modpack-tags.py [server-dir [depth]]
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
|
||||
random.seed(42)
|
||||
|
||||
SERVER_DIR = sys.argv[1] if len(sys.argv) > 1 else "/tmp/mc-bench/vanilla"
|
||||
DEPTH = int(sys.argv[2]) if len(sys.argv) > 2 else 16
|
||||
NS_COUNT = int(sys.argv[3]) if len(sys.argv) > 3 else 200
|
||||
CROSS_REFS = int(sys.argv[4]) if len(sys.argv) > 4 else 6
|
||||
|
||||
# Real mod names first, padded with synthetic to reach NS_COUNT
|
||||
_REAL_NS = [
|
||||
"create", "ae2", "mekanism", "thermal", "botania",
|
||||
"immersiveengineering", "pneumaticraft", "naturesaura", "tinkers", "ars_nouveau",
|
||||
"occultism", "apotheosis", "cofh_core", "sophisticated_storage", "cyclic",
|
||||
"waystones", "refined_storage", "functional_storage", "modular_routers", "pipez",
|
||||
]
|
||||
_synth = max(0, NS_COUNT - len(_REAL_NS))
|
||||
MOD_NAMESPACES = _REAL_NS[:min(len(_REAL_NS), NS_COUNT)] + [f"simmod_{i:03d}" for i in range(_synth)]
|
||||
|
||||
WORLD_DIR = os.path.join(SERVER_DIR, "world")
|
||||
DATAPACK_DIR = os.path.join(WORLD_DIR, "datapacks", "modpack-sim", "data")
|
||||
|
||||
# ── Tag name helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
def ns_base(ns): return f"{ns}:aaa_base"
|
||||
def ns_left(ns, d): return f"{ns}:bbb_tier_{d:02d}_left"
|
||||
def ns_right(ns, d): return f"{ns}:bbb_tier_{d:02d}_right"
|
||||
def ns_root(ns): return f"{ns}:zzz_root"
|
||||
def ns_mid(ns): return ns_left(ns, DEPTH // 2)
|
||||
|
||||
# Tag JSON value entry helpers
|
||||
def required_ref(tag_id): return f"#{tag_id}"
|
||||
def optional_ref(tag_id): return {"id": f"#{tag_id}", "required": False}
|
||||
|
||||
|
||||
# ── Diamond builder ────────────────────────────────────────────────────────────
|
||||
|
||||
def build_namespace_tags(ns, other_namespaces):
|
||||
"""
|
||||
Build the full tag set for one namespace.
|
||||
|
||||
aaa_base ← leaf (minecraft:iron_ingot), REQUIRED
|
||||
↑ ↑ (required)
|
||||
bbb_tier_01_left bbb_tier_01_right ← both ref aaa_base ×2
|
||||
↑ ↑ (required)
|
||||
bbb_tier_02_* bbb_tier_02_*
|
||||
...
|
||||
bbb_tier_DEPTH_* bbb_tier_DEPTH_*
|
||||
↑ ↑ (OPTIONAL — triggers expensive isCyclic in Pass 2)
|
||||
zzz_root ──optional──> CROSS_REFS × foreign bbb_tier_mid nodes
|
||||
|
||||
Internal edges (required) → added to multimap in Pass 1 (no expensive isCyclic there,
|
||||
because multimap is sparse when each edge is processed in hash-bucket order).
|
||||
|
||||
zzz_root's optional edges → processed in Pass 2 when multimap is FULLY built →
|
||||
each triggers isCyclic that traverses the complete 2^DEPTH diamond.
|
||||
"""
|
||||
tags = {}
|
||||
|
||||
# Leaf: aaa_base (required item ref, no tag deps)
|
||||
tags[ns_base(ns)] = [required_ref("minecraft:iron_ingot")]
|
||||
|
||||
prev_l = ns_base(ns)
|
||||
prev_r = ns_base(ns)
|
||||
|
||||
for d in range(1, DEPTH + 1):
|
||||
l = ns_left(ns, d)
|
||||
r = ns_right(ns, d)
|
||||
# REQUIRED deps: internal diamond edges
|
||||
tags[l] = [required_ref(prev_l), required_ref(prev_r)]
|
||||
tags[r] = [required_ref(prev_l), required_ref(prev_r)]
|
||||
prev_l, prev_r = l, r
|
||||
|
||||
# zzz_root: OPTIONAL deps on top-tier diamond nodes → triggers exponential isCyclic
|
||||
cross = [
|
||||
optional_ref(ns_mid(o))
|
||||
for o in random.sample(other_namespaces, min(CROSS_REFS, len(other_namespaces)))
|
||||
]
|
||||
tags[ns_root(ns)] = [optional_ref(prev_l), optional_ref(prev_r)] + cross
|
||||
|
||||
return tags
|
||||
|
||||
|
||||
# ── Writers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def write_namespace_tags(ns, tags):
|
||||
for tag_id, values in tags.items():
|
||||
tag_ns, path = tag_id.split(":", 1)
|
||||
tag_file = os.path.join(DATAPACK_DIR, tag_ns, "tags", "item", f"{path}.json")
|
||||
os.makedirs(os.path.dirname(tag_file), exist_ok=True)
|
||||
with open(tag_file, "w") as f:
|
||||
json.dump({"replace": False, "values": values}, f, indent=2)
|
||||
|
||||
|
||||
def write_common_tags():
|
||||
"""Global aggregator in 'c' namespace + conventional per-mod aliases."""
|
||||
common_dir = os.path.join(DATAPACK_DIR, "c", "tags", "item")
|
||||
os.makedirs(common_dir, exist_ok=True)
|
||||
|
||||
# Top-level aggregator (optional refs — if a mod's root doesn't load, skip it)
|
||||
combined = {
|
||||
"replace": False,
|
||||
"values": [optional_ref(ns_root(ns)) for ns in MOD_NAMESPACES]
|
||||
}
|
||||
with open(os.path.join(common_dir, "zzz_combined_ingots.json"), "w") as f:
|
||||
json.dump(combined, f, indent=2)
|
||||
|
||||
# Per-mod aliases
|
||||
ingots_dir = os.path.join(common_dir, "ingots")
|
||||
os.makedirs(ingots_dir, exist_ok=True)
|
||||
for ns in MOD_NAMESPACES:
|
||||
with open(os.path.join(ingots_dir, f"{ns}.json"), "w") as f:
|
||||
json.dump({"replace": False, "values": [optional_ref(ns_root(ns))]}, f, indent=2)
|
||||
|
||||
|
||||
def write_pack_meta():
|
||||
meta_dir = os.path.join(WORLD_DIR, "datapacks", "modpack-sim")
|
||||
os.makedirs(meta_dir, exist_ok=True)
|
||||
with open(os.path.join(meta_dir, "pack.mcmeta"), "w") as f:
|
||||
json.dump({
|
||||
"pack": {
|
||||
"pack_format": 71,
|
||||
"description": (
|
||||
f"MC-0001 trigger: depth {DEPTH}, "
|
||||
f"{len(MOD_NAMESPACES)} namespaces, optional root deps"
|
||||
)
|
||||
}
|
||||
}, f, indent=2)
|
||||
|
||||
|
||||
# ── Main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
print(f"Generating modpack datapack → {SERVER_DIR}/world/datapacks/modpack-sim/")
|
||||
print(f" depth: {DEPTH}, namespaces: {len(MOD_NAMESPACES)}, cross-refs/root: {CROSS_REFS}")
|
||||
|
||||
# Estimate: isCyclic traversal for 2 direct + CROSS_REFS foreign optional deps per zzz_root
|
||||
direct_calls = 2 * (2 ** (DEPTH + 1)) # per namespace: 2 roots × 2^(D+1) nodes
|
||||
cross_calls = CROSS_REFS * (2 ** (DEPTH // 2 + 1))
|
||||
vanilla_calls = len(MOD_NAMESPACES) * (direct_calls + cross_calls)
|
||||
patched_calls = len(MOD_NAMESPACES) * (2 + CROSS_REFS) # O(1) per call with visited set
|
||||
|
||||
print(f" expected vanilla isCyclic nodes visited: ~{vanilla_calls:,}")
|
||||
print(f" expected patched isCyclic nodes visited: ~{patched_calls:,}")
|
||||
print(f" theoretical speedup: ~{vanilla_calls // max(patched_calls, 1):,}×")
|
||||
|
||||
write_pack_meta()
|
||||
write_common_tags()
|
||||
|
||||
total_tags = 0
|
||||
for ns in MOD_NAMESPACES:
|
||||
others = [o for o in MOD_NAMESPACES if o != ns]
|
||||
tags = build_namespace_tags(ns, others)
|
||||
write_namespace_tags(ns, tags)
|
||||
total_tags += len(tags)
|
||||
|
||||
print(f" wrote {total_tags} tag files across {len(MOD_NAMESPACES)} namespaces")
|
||||
print("Done.")
|
||||
193
tools/mc-bench/raw-connect.mjs
Normal file
193
tools/mc-bench/raw-connect.mjs
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
/**
|
||||
* raw-connect.mjs — raw Minecraft protocol handshake for any server version.
|
||||
*
|
||||
* Performs the LOGIN handshake only (does not need minecraft-data for the version).
|
||||
* Measures connection accept latency and capacity: how many simultaneous TCP
|
||||
* connections the server accepts before refusing.
|
||||
*
|
||||
* Protocol: Minecraft Handshake → Login Start → (server login success or kick)
|
||||
* Works with any version since the handshake packet structure is stable.
|
||||
*
|
||||
* Usage: node raw-connect.mjs <host> <port> <count>
|
||||
*/
|
||||
import net from 'net';
|
||||
|
||||
const HOST = process.argv[2] || 'localhost';
|
||||
const PORT = parseInt(process.argv[3] || '25565');
|
||||
const COUNT = parseInt(process.argv[4] || '20');
|
||||
|
||||
// ── Minecraft VarInt encoding ─────────────────────────────────────────────────
|
||||
function writeVarInt(val) {
|
||||
const bytes = [];
|
||||
do {
|
||||
let b = val & 0x7f;
|
||||
val >>>= 7;
|
||||
if (val !== 0) b |= 0x80;
|
||||
bytes.push(b);
|
||||
} while (val !== 0);
|
||||
return Buffer.from(bytes);
|
||||
}
|
||||
|
||||
function writeString(str) {
|
||||
const strBuf = Buffer.from(str, 'utf8');
|
||||
return Buffer.concat([writeVarInt(strBuf.length), strBuf]);
|
||||
}
|
||||
|
||||
function packet(id, ...bufs) {
|
||||
const payload = Buffer.concat([writeVarInt(id), ...bufs]);
|
||||
return Buffer.concat([writeVarInt(payload.length), payload]);
|
||||
}
|
||||
|
||||
// ── Handshake packet (ID 0x00, state HANDSHAKING) ────────────────────────────
|
||||
// Fields: protocol_version (VarInt), server_address (string), server_port (u16), next_state (VarInt=2 login)
|
||||
function handshakePacket(protocolVersion, host, port) {
|
||||
return packet(0x00,
|
||||
writeVarInt(protocolVersion), // protocol version
|
||||
writeString(host),
|
||||
Buffer.from([port >> 8, port & 0xff]),
|
||||
writeVarInt(2) // next state: LOGIN
|
||||
);
|
||||
}
|
||||
|
||||
// ── Login Start packet (ID 0x00, state LOGIN) ─────────────────────────────────
|
||||
// Fields: name (string), uuid (optional)
|
||||
function loginStartPacket(name) {
|
||||
return packet(0x00, writeString(name));
|
||||
}
|
||||
|
||||
// ── Connect one bot ───────────────────────────────────────────────────────────
|
||||
async function connectBot(index, protocolVersion) {
|
||||
return new Promise((resolve) => {
|
||||
const name = `bench_${String(index).padStart(3, '0')}`;
|
||||
const t0 = Date.now();
|
||||
const sock = new net.Socket();
|
||||
let done = false;
|
||||
|
||||
const finish = (status, detail) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
const ms = Date.now() - t0;
|
||||
sock.destroy();
|
||||
resolve({ index, name, status, detail, ms });
|
||||
};
|
||||
|
||||
sock.setTimeout(15000);
|
||||
sock.on('timeout', () => finish('timeout', 'connection timed out'));
|
||||
sock.on('error', (e) => finish('error', e.message));
|
||||
|
||||
sock.connect(PORT, HOST, () => {
|
||||
// Connected — send handshake then login start
|
||||
sock.write(handshakePacket(protocolVersion, HOST, PORT));
|
||||
sock.write(loginStartPacket(name));
|
||||
});
|
||||
|
||||
sock.on('data', (buf) => {
|
||||
// We just need to know the server responded (any response = accepted)
|
||||
// Response could be: Login Success (0x02), Login Disconnect (0x00), Set Compression (0x03)
|
||||
if (buf.length > 0) {
|
||||
// Check first packet byte after length VarInt: try to identify packet type
|
||||
const packetByte = buf[buf.length > 2 ? 2 : 1];
|
||||
let statusStr = 'connected';
|
||||
if (packetByte === 0x00) statusStr = 'kicked'; // Disconnect
|
||||
else if (packetByte === 0x02) statusStr = 'accepted'; // Login Success
|
||||
else if (packetByte === 0x03) statusStr = 'compress'; // Set Compression (pre-auth)
|
||||
finish(statusStr, `${buf.length} bytes`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Probe server for protocol version ────────────────────────────────────────
|
||||
async function probeVersion() {
|
||||
return new Promise((resolve) => {
|
||||
const sock = new net.Socket();
|
||||
sock.setTimeout(5000);
|
||||
sock.on('error', () => resolve(0));
|
||||
sock.on('timeout', () => { sock.destroy(); resolve(0); });
|
||||
|
||||
sock.connect(PORT, HOST, () => {
|
||||
// Status handshake: next_state=1 (STATUS)
|
||||
const handshake = packet(0x00,
|
||||
writeVarInt(0), // version 0 = unknown
|
||||
writeString(HOST),
|
||||
Buffer.from([PORT >> 8, PORT & 0xff]),
|
||||
writeVarInt(1) // STATUS
|
||||
);
|
||||
const statusReq = packet(0x00); // Status Request
|
||||
sock.write(Buffer.concat([handshake, statusReq]));
|
||||
});
|
||||
|
||||
sock.on('data', (buf) => {
|
||||
// Try to parse JSON from status response
|
||||
try {
|
||||
const str = buf.toString();
|
||||
const jsonStart = str.indexOf('{');
|
||||
const jsonEnd = str.lastIndexOf('}');
|
||||
if (jsonStart >= 0 && jsonEnd > jsonStart) {
|
||||
const json = JSON.parse(str.slice(jsonStart, jsonEnd + 1));
|
||||
const protocol = json?.version?.protocol || 0;
|
||||
const versionName = json?.version?.name || 'unknown';
|
||||
resolve({ protocol, name: versionName, players: json?.players });
|
||||
}
|
||||
} catch (_) {}
|
||||
sock.destroy();
|
||||
resolve(0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Main ─────────────────────────────────────────────────────────────────────
|
||||
console.log(`=== Minecraft Raw Connection Benchmark ===`);
|
||||
console.log(` server: ${HOST}:${PORT}`);
|
||||
console.log(` bots: ${COUNT}`);
|
||||
|
||||
// Probe server version
|
||||
const serverInfo = await probeVersion();
|
||||
const protocolVersion = typeof serverInfo === 'object' ? serverInfo.protocol : 0;
|
||||
if (typeof serverInfo === 'object') {
|
||||
console.log(` server version: ${serverInfo.name} (protocol ${protocolVersion})`);
|
||||
if (serverInfo.players) {
|
||||
console.log(` online: ${serverInfo.players.online}/${serverInfo.players.max}`);
|
||||
}
|
||||
}
|
||||
console.log(``);
|
||||
|
||||
if (!protocolVersion) {
|
||||
console.log('Could not probe server. Is it running?');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const wallStart = Date.now();
|
||||
const results = [];
|
||||
|
||||
// Spawn all connections with stagger
|
||||
for (let i = 0; i < COUNT; i++) {
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
results.push(connectBot(i, protocolVersion));
|
||||
process.stdout.write(`\r connecting ${i + 1}/${COUNT}...`);
|
||||
}
|
||||
console.log('');
|
||||
const all = await Promise.all(results);
|
||||
const wallMs = Date.now() - wallStart;
|
||||
|
||||
// Analyse
|
||||
const accepted = all.filter(r => ['accepted','compress','connected','kicked'].includes(r.status));
|
||||
const errors = all.filter(r => r.status === 'error' || r.status === 'timeout');
|
||||
const latencies = accepted.map(r => r.ms).sort((a,b) => a-b);
|
||||
|
||||
function pct(arr, p) {
|
||||
const i = Math.ceil(p/100*arr.length)-1;
|
||||
return arr[Math.max(0, Math.min(arr.length-1, i))];
|
||||
}
|
||||
|
||||
console.log(`=== Results ===`);
|
||||
console.log(` wall time: ${(wallMs/1000).toFixed(1)} s`);
|
||||
console.log(` accepted: ${accepted.length}/${COUNT}`);
|
||||
console.log(` errors: ${errors.length}`);
|
||||
if (latencies.length > 0) {
|
||||
console.log(` handshake latency:`);
|
||||
console.log(` p50: ${pct(latencies, 50)} ms`);
|
||||
console.log(` p95: ${pct(latencies, 95)} ms`);
|
||||
console.log(` max: ${latencies[latencies.length-1]} ms`);
|
||||
}
|
||||
console.log(``);
|
||||
35
tools/mc-bench/rcon.py
Normal file
35
tools/mc-bench/rcon.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Minimal RCON client for Minecraft server command injection."""
|
||||
import socket, struct, sys, time
|
||||
|
||||
def rcon(host, port, password, command, timeout=10):
|
||||
def packet(pid, ptype, body):
|
||||
b = body.encode() + b'\x00\x00'
|
||||
return struct.pack('<iii', 4 + 4 + len(b), pid, ptype) + b
|
||||
|
||||
def recv_packet(s):
|
||||
hdr = b''
|
||||
while len(hdr) < 4:
|
||||
hdr += s.recv(4 - len(hdr))
|
||||
length = struct.unpack('<i', hdr)[0]
|
||||
data = b''
|
||||
while len(data) < length:
|
||||
data += s.recv(length - len(data))
|
||||
return struct.unpack('<ii', data[:8])[0], struct.unpack('<ii', data[:8])[1], data[8:-2].decode('utf-8', errors='replace')
|
||||
|
||||
s = socket.socket()
|
||||
s.settimeout(timeout)
|
||||
s.connect((host, port))
|
||||
s.send(packet(1, 3, password)) # auth
|
||||
recv_packet(s)
|
||||
s.send(packet(2, 2, command)) # command
|
||||
_, _, resp = recv_packet(s)
|
||||
s.close()
|
||||
return resp
|
||||
|
||||
if __name__ == '__main__':
|
||||
host = sys.argv[1] if len(sys.argv) > 1 else 'localhost'
|
||||
port = int(sys.argv[2]) if len(sys.argv) > 2 else 25575
|
||||
password = sys.argv[3] if len(sys.argv) > 3 else 'benchpass'
|
||||
command = ' '.join(sys.argv[4:]) if len(sys.argv) > 4 else 'list'
|
||||
print(rcon(host, port, password, command))
|
||||
129
tools/mc-bench/sim-players.mjs
Normal file
129
tools/mc-bench/sim-players.mjs
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
/**
|
||||
* sim-players.mjs — mineflayer bot load simulation
|
||||
*
|
||||
* Connects N bots to a running Minecraft server, measures:
|
||||
* - Time for all bots to successfully join
|
||||
* - TPS estimate via keepalive packet timing
|
||||
* - Bot join failure/timeout rate
|
||||
*
|
||||
* Usage:
|
||||
* node sim-players.mjs [host] [port] [bot-count]
|
||||
*
|
||||
* Example:
|
||||
* node sim-players.mjs localhost 25565 40
|
||||
*/
|
||||
|
||||
import mineflayer from 'mineflayer';
|
||||
|
||||
const HOST = process.argv[2] || 'localhost';
|
||||
const PORT = parseInt(process.argv[3] || '25565');
|
||||
const COUNT = parseInt(process.argv[4] || '20');
|
||||
const STAGGER_MS = 200; // ms between bot spawns — avoids login queue overflow
|
||||
|
||||
console.log(`=== Minecraft Bot Load Simulation ===`);
|
||||
console.log(` server: ${HOST}:${PORT}`);
|
||||
console.log(` bots: ${COUNT}`);
|
||||
console.log(` stagger: ${STAGGER_MS}ms between joins`);
|
||||
console.log(``);
|
||||
|
||||
const results = [];
|
||||
const wallStart = Date.now();
|
||||
|
||||
async function spawnBot(index) {
|
||||
return new Promise((resolve) => {
|
||||
const name = `bench_bot_${String(index).padStart(3, '0')}`;
|
||||
const t_spawn = Date.now();
|
||||
|
||||
const bot = mineflayer.createBot({
|
||||
host: HOST,
|
||||
port: PORT,
|
||||
username: name,
|
||||
version: false, // auto-detect from server handshake
|
||||
auth: 'offline',
|
||||
hideErrors: false,
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
results.push({ index, name, status: 'timeout', latency: Date.now() - t_spawn });
|
||||
try { bot.end(); } catch (_) {}
|
||||
resolve();
|
||||
}, 30000);
|
||||
|
||||
bot.once('spawn', () => {
|
||||
clearTimeout(timeout);
|
||||
const latency = Date.now() - t_spawn;
|
||||
results.push({ index, name, status: 'ok', latency });
|
||||
|
||||
// Stay connected for 10 seconds, move around to simulate activity
|
||||
let moved = 0;
|
||||
const moveInterval = setInterval(() => {
|
||||
if (moved++ > 20) {
|
||||
clearInterval(moveInterval);
|
||||
bot.end();
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
// Random movement to generate chunk loading
|
||||
const dx = (Math.random() - 0.5) * 2;
|
||||
const dz = (Math.random() - 0.5) * 2;
|
||||
bot.entity.position.x += dx;
|
||||
bot.entity.position.z += dz;
|
||||
}, 500);
|
||||
});
|
||||
|
||||
bot.on('error', (err) => {
|
||||
clearTimeout(timeout);
|
||||
results.push({ index, name, status: 'error', msg: err.message, latency: Date.now() - t_spawn });
|
||||
resolve();
|
||||
});
|
||||
|
||||
bot.on('kicked', (reason) => {
|
||||
clearTimeout(timeout);
|
||||
results.push({ index, name, status: 'kicked', msg: reason, latency: Date.now() - t_spawn });
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Spawn bots with stagger
|
||||
const promises = [];
|
||||
for (let i = 0; i < COUNT; i++) {
|
||||
await new Promise(r => setTimeout(r, STAGGER_MS));
|
||||
promises.push(spawnBot(i));
|
||||
process.stdout.write(`\r spawning bot ${i + 1}/${COUNT}...`);
|
||||
}
|
||||
|
||||
console.log(`\r all ${COUNT} bots spawned, waiting for joins...`);
|
||||
await Promise.all(promises);
|
||||
|
||||
const wallMs = Date.now() - wallStart;
|
||||
|
||||
// Analyse results
|
||||
const ok = results.filter(r => r.status === 'ok');
|
||||
const failed = results.filter(r => r.status !== 'ok');
|
||||
const latencies = ok.map(r => r.latency).sort((a, b) => a - b);
|
||||
|
||||
function pct(arr, p) {
|
||||
const i = Math.ceil(p / 100 * arr.length) - 1;
|
||||
return arr[Math.max(0, Math.min(arr.length - 1, i))];
|
||||
}
|
||||
|
||||
console.log(``);
|
||||
console.log(`=== Results ===`);
|
||||
console.log(` wall time: ${(wallMs / 1000).toFixed(1)} s`);
|
||||
console.log(` successful: ${ok.length} / ${COUNT}`);
|
||||
console.log(` failed/kicked: ${failed.length}`);
|
||||
if (latencies.length > 0) {
|
||||
console.log(` join latency:`);
|
||||
console.log(` p50: ${pct(latencies, 50)} ms`);
|
||||
console.log(` p95: ${pct(latencies, 95)} ms`);
|
||||
console.log(` p99: ${pct(latencies, 99)} ms`);
|
||||
console.log(` max: ${latencies[latencies.length - 1]} ms`);
|
||||
}
|
||||
if (failed.length > 0) {
|
||||
console.log(` failures:`);
|
||||
for (const f of failed.slice(0, 5)) {
|
||||
console.log(` ${f.name}: ${f.status} — ${f.msg || ''}`);
|
||||
}
|
||||
}
|
||||
console.log(``);
|
||||
Loading…
Add table
Add a link
Reference in a new issue