From fb6f60a0a14af0634f59c52d27f9fc23ee10678e Mon Sep 17 00:00:00 2001 From: Irina Nazarova Date: Sat, 20 Jun 2026 20:29:04 -0700 Subject: [PATCH 01/10] Add socketioxide (Rust Socket.io server) as a comparison target Library author asked on X if we'd benchmark their crate alongside Node Socket.io, uWS, and AnyCable. socketioxide speaks the Socket.io wire protocol, so the bench-runner's existing socket.io-client driver works against it unchanged. The work is server-side. New `socketioxide/` directory carries the Rust Cargo project, the Dockerfile, and a railway.toml. The server mirrors the shape of backend/src/socketio/server.ts: /health, /stats, /_broadcast, /publish-local, plus a Connection State Recovery toggle via SOCKETIO_CSR=1 (FIXME'd in main.rs because the crate's CSR API has shifted across versions). Manifest entries land under each rubric (latency 1K/10K, jitter, idle, avalanche escalation), targeting the existing bench-jitter-socketio, bench-idle-socketio, and bench-avalanche-socketio endpoints via ?serverUrl=. Baselines are empty until first run. docs/socketioxide-comparison.md tracks status, open questions for the library author, and the eventual results. README links to it under 'Additional target on request'. Build + tests green. Rust code is unverified by compile from this end; the GitHub issue will tag the library author for review. --- .gitignore | 1 + README.md | 2 + backend/src/bench/tests-manifest.ts | 120 ++++++++++++++++ docs/socketioxide-comparison.md | 104 ++++++++++++++ socketioxide/.gitignore | 2 + socketioxide/Cargo.toml | 23 +++ socketioxide/Dockerfile | 26 ++++ socketioxide/railway.toml | 2 + socketioxide/src/main.rs | 209 ++++++++++++++++++++++++++++ 9 files changed, 489 insertions(+) create mode 100644 docs/socketioxide-comparison.md create mode 100644 socketioxide/.gitignore create mode 100644 socketioxide/Cargo.toml create mode 100644 socketioxide/Dockerfile create mode 100644 socketioxide/railway.toml create mode 100644 socketioxide/src/main.rs diff --git a/.gitignore b/.gitignore index 1a06409..dafc8dd 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ anycable-go-darwin-* anycable-go-windows-* anycable-go-mrb-* anycable-thruster-* +.DS_Store diff --git a/README.md b/README.md index 2b52356..5dc1288 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ The repo behind [anycable.io/compare/nodejs-websocket](https://anycable.io/compa Setups under test: default Socket.io, Socket.io + Connection State Recovery, uWebSockets.js, AnyCable OSS, AnyCable Pro. +Additional target on request: [socketioxide](https://github.com/totodore/socketioxide) (Rust Socket.io server). Scaffolded in `socketioxide/`; results tracked in [`docs/socketioxide-comparison.md`](./docs/socketioxide-comparison.md). + Methodology, traps, and the bugs we caught in our own setup: [`docs/methodology.md`](./docs/methodology.md). Below: the numbers and how to rerun them. ## Headlines diff --git a/backend/src/bench/tests-manifest.ts b/backend/src/bench/tests-manifest.ts index 18e2ded..4a09baf 100644 --- a/backend/src/bench/tests-manifest.ts +++ b/backend/src/bench/tests-manifest.ts @@ -93,6 +93,13 @@ const TARGETS = { anycablePro: "ws://anycable-go-pro.railway.internal:8080/cable", anycableProBroadcast: "http://anycable-go-pro.railway.internal:8080/_broadcast", + // socketioxide is the Rust implementation of the Socket.io protocol. + // Wire-compatible with socket.io-client, so the existing bench-runner + // `bench-jitter-socketio`/`-csr` endpoints work as-is with + // `?serverUrl=`. Default service ships SOCKETIO_CSR=0; the CSR + // variant deploys the same image with SOCKETIO_CSR=1 set at boot. + socketioxide: "http://socketioxide-server.railway.internal:3000", + socketioxideCsr: "http://socketioxide-server-csr.railway.internal:3000", }; // Common knobs reused across tests. Keep these explicit so the manifest @@ -202,6 +209,45 @@ export const tests: TestSpec[] = [ params: { n: 10000, ...LATENCY_10K, cableUrl: TARGETS.anycablePro, broadcastUrl: TARGETS.anycableProBroadcast }, baseline: { "latencyRawMs.p50": 234, "latencyRawMs.p99": 694, deliveryRatePct: 100 }, }, + // socketioxide: same Socket.io wire protocol, Rust implementation. Uses + // the bench-jitter-socketio endpoint with ?serverUrl=. No + // baselines yet — first run pending. See docs/socketioxide-comparison.md. + { + id: "latency-socketioxide-1k", + description: "Roundtrip latency, socketioxide (Rust), 1K subs", + category: "latency", + endpoint: "bench-jitter-socketio", + mode: "sync", + params: { n: 1000, ...LATENCY_1K, serverUrl: TARGETS.socketioxide }, + baseline: {}, + }, + { + id: "latency-socketioxide-10k", + description: "Roundtrip latency, socketioxide (Rust), 10K subs", + category: "latency", + endpoint: "bench-jitter-socketio", + mode: "sync", + params: { n: 10000, ...LATENCY_10K, serverUrl: TARGETS.socketioxide }, + baseline: {}, + }, + { + id: "latency-socketioxide-csr-1k", + description: "Roundtrip latency, socketioxide + CSR, 1K subs", + category: "latency", + endpoint: "bench-jitter-socketio-csr", + mode: "sync", + params: { n: 1000, ...LATENCY_1K, serverUrl: TARGETS.socketioxideCsr }, + baseline: {}, + }, + { + id: "latency-socketioxide-csr-10k", + description: "Roundtrip latency, socketioxide + CSR, 10K subs", + category: "latency", + endpoint: "bench-jitter-socketio-csr", + mode: "sync", + params: { n: 10000, ...LATENCY_10K, serverUrl: TARGETS.socketioxideCsr }, + baseline: {}, + }, // ------------------------------------------------------------------------- // Reliability (jitter under WiFi-drop pattern) @@ -265,6 +311,27 @@ export const tests: TestSpec[] = [ params: { n: 10000, ...JITTER_10K, cableUrl: TARGETS.anycablePro, broadcastUrl: TARGETS.anycableProBroadcast, samplesCap: 5000 }, baseline: { deliveryRatePct: 100, lostDeliveries: 0, "latencyRawMs.p95": 4100, "latencyRawMs.p99": 6200 }, }, + // socketioxide jitter rows. Default (no CSR) should land in the + // at-most-once band with Socket.io and uWS; the CSR row tests whether + // the Rust impl delivers 100% with a different replay-tail shape. + { + id: "jitter-socketioxide-10k", + description: "Reliability under WiFi jitter, socketioxide default, 10K", + category: "jitter", + endpoint: "bench-jitter-socketio", + mode: "async", + params: { n: 10000, ...JITTER_10K, serverUrl: TARGETS.socketioxide, samplesCap: 5000 }, + baseline: {}, + }, + { + id: "jitter-socketioxide-csr-10k", + description: "Reliability under WiFi jitter, socketioxide + CSR, 10K", + category: "jitter", + endpoint: "bench-jitter-socketio-csr", + mode: "async", + params: { n: 10000, ...JITTER_10K, serverUrl: TARGETS.socketioxideCsr, samplesCap: 5000 }, + baseline: {}, + }, // ------------------------------------------------------------------------- // Whispers (1K × 10 rooms, 100 peers/room) @@ -441,6 +508,21 @@ export const tests: TestSpec[] = [ baseline: { connected: 1000000, ramKbPerConnected: 5 }, driftThresholdPct: 60, }, + // socketioxide idle: same multi-shard fan-out, targets the Rust service. + // No targetServiceId yet — fill in once the Railway service is created + // so the runner can pull memory/CPU from Railway metrics. + { + id: "idle-socketioxide", + description: "Idle connections held, socketioxide (Rust), 1M target", + category: "idle", + endpoint: "bench-idle-socketio", + mode: "multi-shard", + numShards: 50, + perShardN: 20000, + params: { hold: 120, ramp: 200, stream: "idle-rebaseline", serverUrl: TARGETS.socketioxide }, + baseline: {}, + driftThresholdPct: 60, + }, // ------------------------------------------------------------------------- // Avalanche (in-process WS layer restart under N held connections). @@ -536,4 +618,42 @@ export const tests: TestSpec[] = [ baseline: { reconnectRatePct: 0 }, driftThresholdPct: 100, }, + // socketioxide avalanche escalation: mirror the Socket.io ladder (5K, 10K, + // 15K, 20K, 25K). Same redeploy mechanism, just pointed at the Rust service. + // The interesting question is whether Rust's event loop pushes the cliff out + // further than Node's, or whether the architectural problem (in-process WS + // dies with the app) holds the shape across languages. + { + id: "avalanche-socketioxide-5k", + description: "Avalanche: 5K socketioxide clients, server redeploy", + category: "avalanche", + endpoint: "bench-avalanche-socketio", + mode: "avalanche", + redeployServiceName: "socketioxide-server", + params: { n: 5000, ramp: 200, prearm: 90, recoveryWait: 180, stream: "avalanche-sox-5k", serverUrl: TARGETS.socketioxide }, + baseline: {}, + driftThresholdPct: 100, + }, + { + id: "avalanche-socketioxide-10k", + description: "Avalanche: 10K socketioxide clients, server redeploy", + category: "avalanche", + endpoint: "bench-avalanche-socketio", + mode: "avalanche", + redeployServiceName: "socketioxide-server", + params: { n: 10000, ramp: 200, prearm: 120, recoveryWait: 240, stream: "avalanche-sox-10k", serverUrl: TARGETS.socketioxide }, + baseline: {}, + driftThresholdPct: 100, + }, + { + id: "avalanche-socketioxide-20k", + description: "Avalanche: 20K socketioxide clients, server redeploy", + category: "avalanche", + endpoint: "bench-avalanche-socketio", + mode: "avalanche", + redeployServiceName: "socketioxide-server", + params: { n: 20000, ramp: 200, prearm: 240, recoveryWait: 600, stream: "avalanche-sox-20k", serverUrl: TARGETS.socketioxide }, + baseline: {}, + driftThresholdPct: 100, + }, ]; diff --git a/docs/socketioxide-comparison.md b/docs/socketioxide-comparison.md new file mode 100644 index 0000000..6bfed16 --- /dev/null +++ b/docs/socketioxide-comparison.md @@ -0,0 +1,104 @@ +# socketioxide in the comparison + +[socketioxide](https://github.com/totodore/socketioxide) is a Rust +implementation of the Socket.io v4+ protocol. The library's author asked us +to include it in the benchmark, and it's a good fit: same wire protocol as +the Node Socket.io server we already test, so the bench-runner's existing +`socket.io-client` driver works against it unchanged. The work is server-side. + +This page tracks what we added, what the open questions are, and what the +numbers look like once we have them. + +## Status + +- **Server scaffold:** `socketioxide/` in this repo. Cargo project plus + Dockerfile that mirrors the shape of `backend/src/socketio/server.ts`: + `/health`, `/stats`, `/_broadcast`, `/publish-local`. Needs a compile + + review pass by someone with current `socketioxide` API expertise; the + Connection State Recovery section in `socketioxide/src/main.rs` is the + rough spot (the crate's CSR API has moved across versions). +- **Bench-runner endpoints:** none new. The Rust server speaks the Socket.io + wire protocol, so the existing `bench-jitter-socketio`, + `bench-jitter-socketio-csr`, `bench-idle-socketio`, and + `bench-avalanche-socketio` endpoints all accept it via `?serverUrl=...`. +- **Manifest entries:** added under each rubric in + `backend/src/bench/tests-manifest.ts`. Baselines are empty until the first + run lands. +- **Railway services:** two services to deploy from this directory: + `socketioxide-server` (default mode) and `socketioxide-server-csr` + (`SOCKETIO_CSR=1` set at boot). Both on the same hardware tier as the + other Socket.io targets so the comparison stays apples-to-apples. + +## Why this strengthens the comparison + +The page's architectural claim is that at-most-once delivery losses under +jitter and the in-process avalanche on deploy are properties of the +deployment shape, not of any particular implementation language. Adding a +Rust at-most-once Socket.io server lets us test that claim across runtimes. +Three outcomes are possible, all useful: + +- **Same shape, same numbers.** Rust loses ~16% under jitter, all + connections die on deploy. Confirms the claim: it was always about + topology, never the runtime. +- **Same shape, better numbers.** Rust event loop holds more connections + before the cliff (avalanche pushes out from 25K to 50K, say), but still + goes to zero at deploy. Refines the claim: implementation slows the + cliff, doesn't move it. +- **Different shape.** If socketioxide ships CSR on by default or stays up + through a deploy somehow, the comparison gets more interesting and the + page narrative gets richer. + +Either way, the result is a stronger page, not a weaker one. + +## Rubrics + manifest IDs + +| Rubric | Default | + CSR | +|---|---|---| +| Latency 1K | `latency-socketioxide-1k` | `latency-socketioxide-csr-1k` | +| Latency 10K | `latency-socketioxide-10k` | `latency-socketioxide-csr-10k` | +| Jitter 10K | `jitter-socketioxide-10k` | `jitter-socketioxide-csr-10k` | +| Idle 1M target | `idle-socketioxide` | n/a | +| Avalanche 5K | `avalanche-socketioxide-5k` | n/a | +| Avalanche 10K | `avalanche-socketioxide-10k` | n/a | +| Avalanche 20K | `avalanche-socketioxide-20k` | n/a | + +Whispers and throughput entries can be added the same way once we have +the latency + jitter numbers and we know the server config holds up. + +## Open questions for the library author + +Tagged in the GitHub issue: + +1. **CSR API.** The `socketioxide/src/main.rs` skeleton has the + Connection State Recovery builder call commented out with a FIXME. + Confirm the right shape for the pinned crate version (or bump + the version, whichever is cleaner). +2. **Recovered flag.** When a CSR resume succeeds, we need to bump the + `recovered` counter so `/stats-csr` reports it. The right accessor + on the socket varies across crate versions. +3. **Production-shaped publisher.** Our `/_broadcast` handler calls + `io.to(stream).emit(...).await`. That's the natural shape but it + might not match the recommended pattern for socketioxide in + production. Open to a PR that swaps it. +4. **Default config knobs.** Anything we should tune for the comparison + to be fair? Compression, ping intervals, buffer sizes. Defaults + here match the Node Socket.io server's bench config; happy to take + a config patch. + +## Results + +*Run pending. Numbers will land here once the services are deployed and the +manifest is rerun against them.* + +To run the socketioxide-only subset: + +```bash +cd backend +BENCH_RUNNER_URL=https://bench-runner-production.up.railway.app \ +BENCH_RUNNER_TOKEN= \ +FILTER=socketioxide \ + npm run bench:rebaseline +``` + +Multi-shard idle and avalanche entries gate behind `INCLUDE_IDLE=1` and +`INCLUDE_AVALANCHE=1` as usual. diff --git a/socketioxide/.gitignore b/socketioxide/.gitignore new file mode 100644 index 0000000..2c96eb1 --- /dev/null +++ b/socketioxide/.gitignore @@ -0,0 +1,2 @@ +target/ +Cargo.lock diff --git a/socketioxide/Cargo.toml b/socketioxide/Cargo.toml new file mode 100644 index 0000000..0a327dc --- /dev/null +++ b/socketioxide/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "socketioxide-bench-server" +version = "0.1.0" +edition = "2021" + +[dependencies] +# Pin to a recent socketioxide; the API for CSR / connection state +# recovery has shifted across versions, so the library author is the +# right person to bump this if needed. +socketioxide = { version = "0.16", features = ["state-recovery"] } +axum = "0.7" +tokio = { version = "1", features = ["full"] } +tower = "0.5" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tracing = "0.1" +tracing-subscriber = "0.3" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 diff --git a/socketioxide/Dockerfile b/socketioxide/Dockerfile new file mode 100644 index 0000000..2ea661a --- /dev/null +++ b/socketioxide/Dockerfile @@ -0,0 +1,26 @@ +# Build the socketioxide bench server. +# +# Same shape as backend/Dockerfile: builder stage produces a static binary, +# runtime stage is a thin slim image. Build the image from the repo root: +# +# docker build -f socketioxide/Dockerfile -t socketioxide-server . +# +# Or via railway.toml deploy from this directory. + +FROM rust:1.83-slim AS builder +WORKDIR /src +RUN apt-get update \ + && apt-get install -y --no-install-recommends pkg-config libssl-dev \ + && rm -rf /var/lib/apt/lists/* +COPY Cargo.toml ./ +COPY src/ ./src/ +RUN cargo build --release \ + && strip target/release/socketioxide-bench-server + +FROM debian:bookworm-slim +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY --from=builder /src/target/release/socketioxide-bench-server /usr/local/bin/socketioxide-bench-server +EXPOSE 3000 +CMD ["socketioxide-bench-server"] diff --git a/socketioxide/railway.toml b/socketioxide/railway.toml new file mode 100644 index 0000000..5f9978b --- /dev/null +++ b/socketioxide/railway.toml @@ -0,0 +1,2 @@ +[build] +dockerfilePath = "Dockerfile" diff --git a/socketioxide/src/main.rs b/socketioxide/src/main.rs new file mode 100644 index 0000000..b28c7f3 --- /dev/null +++ b/socketioxide/src/main.rs @@ -0,0 +1,209 @@ +// Socketioxide bench server. Mirrors the shape of backend/src/socketio/server.ts +// so the bench-runner's existing socket.io-client driver can target it +// with `?serverUrl=` and no protocol-specific work needed. +// +// Endpoints: +// GET /health service liveness probe +// GET /stats { connections } +// GET /stats-csr { connections, recovered } (CSR mode) +// POST /_broadcast { stream, data } -> io.to(stream).emit("message", data) +// POST /publish-local in-process publish loop, mirrors the Node server's +// /publish-local for the "publisher inside the WS +// process" diagnostic test. +// +// Env: +// PORT default 3000 +// SOCKETIO_CSR "1" enables Connection State Recovery +// +// FIXME(socketioxide author): the CSR feature flag and the with_state_recovery +// builder call below should be checked against the version pinned in +// Cargo.toml. The version of socketioxide we target has moved around the +// state-recovery API; this is the best-effort shape and very likely needs +// adjustment when first compiled. See the open issue for review. + +use std::env; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use axum::{ + extract::{Query, State}, + http::StatusCode, + response::Json, + routing::{get, post}, + Router, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use socketioxide::{extract::SocketRef, SocketIo}; +use tokio::net::TcpListener; +use tracing::info; + +#[derive(Clone)] +struct AppState { + io: SocketIo, + connections: Arc, + recovered: Arc, +} + +#[derive(Deserialize)] +struct BroadcastBody { + stream: String, + data: Value, +} + +#[derive(Serialize)] +struct Stats { + connections: u64, + recovered: u64, +} + +#[derive(Deserialize)] +struct PublishLocalQuery { + total: Option, + interval: Option, + stream: Option, + delay: Option, +} + +async fn health() -> Json { + Json(json!({ "status": "ok", "mode": "socketioxide" })) +} + +async fn stats(State(state): State) -> Json { + Json(Stats { + connections: state.connections.load(Ordering::Relaxed), + recovered: state.recovered.load(Ordering::Relaxed), + }) +} + +async fn broadcast( + State(state): State, + Json(body): Json, +) -> (StatusCode, Json) { + // The Node server accepts `data` as either a JSON object or a string + // containing JSON (the bench-runner uses the string form). Mirror that + // behaviour so the bench-runner's existing driver works unchanged. + let payload = match body.data { + Value::String(s) => serde_json::from_str::(&s).unwrap_or(Value::String(s)), + other => other, + }; + let _ = state.io.to(body.stream).emit("message", payload).await; + (StatusCode::OK, Json(json!({ "ok": true }))) +} + +async fn publish_local( + State(state): State, + Query(q): Query, +) -> Json { + let total = q.total.unwrap_or(120); + let interval = q.interval.unwrap_or(500); + let stream = q.stream.unwrap_or_else(|| "benchmark".to_string()); + let delay = q.delay.unwrap_or(0); + + let io = state.io.clone(); + let stream_for_task = stream.clone(); + tokio::spawn(async move { + if delay > 0 { + tokio::time::sleep(Duration::from_secs(delay)).await; + } + for seq in 1..=total { + let sent_at = chrono_millis(); + let msg = json!({ "seq": seq, "sentAt": sent_at, "text": format!("msg_{}", seq) }); + let _ = io.to(stream_for_task.clone()).emit("message", msg).await; + tokio::time::sleep(Duration::from_millis(interval)).await; + } + }); + + Json(json!({ + "status": "publishing-local", + "total": total, + "interval": interval, + "stream": stream, + "delay": delay, + })) +} + +fn chrono_millis() -> i64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt::init(); + + let port: u16 = env::var("PORT") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(3000); + + let enable_csr = env::var("SOCKETIO_CSR") + .map(|v| v == "1") + .unwrap_or(false); + + // FIXME(socketioxide author): adjust the builder call below to enable + // Connection State Recovery in the exact way the pinned crate version + // expects. The shape here is illustrative. + let builder = SocketIo::builder(); + // if enable_csr { + // builder = builder.with_state_recovery( + // socketioxide::state_recovery::StateRecoveryConfig::default(), + // ); + // } + let (layer, io) = builder.build_layer(); + + let state = AppState { + io: io.clone(), + connections: Arc::new(AtomicU64::new(0)), + recovered: Arc::new(AtomicU64::new(0)), + }; + + let conns = state.connections.clone(); + let recovered = state.recovered.clone(); + io.ns("/", move |socket: SocketRef| { + conns.fetch_add(1, Ordering::Relaxed); + // FIXME(socketioxide author): when CSR is enabled, the recovered + // flag on the socket should be checked here and `recovered` + // incremented. The exact accessor varies by version. + + socket.on("join", |socket: SocketRef, room: Value| async move { + if let Some(name) = room.as_str() { + let _ = socket.join(name.to_string()); + } + }); + + socket.on("whisper", |socket: SocketRef, data: Value| async move { + if let (Some(room), Some(payload)) = + (data.get(0).and_then(|v| v.as_str()), data.get(1)) + { + let _ = socket + .to(room.to_string()) + .emit("whisper", payload.clone()) + .await; + } + }); + + let conn_dec = conns.clone(); + socket.on_disconnect(move || { + conn_dec.fetch_sub(1, Ordering::Relaxed); + }); + }); + + let app = Router::new() + .route("/health", get(health)) + .route("/stats", get(stats)) + .route("/stats-csr", get(stats)) + .route("/_broadcast", post(broadcast)) + .route("/publish-local", post(publish_local)) + .layer(layer) + .with_state(state); + + let addr = format!("0.0.0.0:{}", port); + let listener = TcpListener::bind(&addr).await.expect("bind"); + info!(addr = %addr, csr = enable_csr, "socketioxide bench server listening"); + axum::serve(listener, app).await.expect("serve"); +} From a542698335273d84df4e477652d9428b20b9d792 Mon Sep 17 00:00:00 2001 From: Irina Nazarova Date: Sat, 20 Jun 2026 22:07:40 -0700 Subject: [PATCH 02/10] =?UTF-8?q?Security:=20npm=20audit=20fix,=20undici?= =?UTF-8?q?=208.2.0=20=E2=86=92=208.5.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven advisories rolled into one high-severity npm-audit row in undici 8.0.0-8.4.1 (cert validation bypass in SOCKS5 ProxyAgent, header injection via Set-Cookie, WS DoS via fragment-count and cumulative- fragment bypasses, HTTP response queue poisoning, Set-Cookie SameSite attribute downgrade, cross-user info disclosure via shared cache whitespace bypass). undici is driver-side (used by the long-timeout Agent in bench scripts), not bench-runner-side. Fix re-resolves within the existing ^8.2.0 caret range; no package.json change. found 0 vulnerabilities --- backend/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index 3c1e4da..ab259a2 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -2024,9 +2024,9 @@ } }, "node_modules/undici": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.2.0.tgz", - "integrity": "sha512-Z+4Hx9GE26Lh9Upwfnc8C7SsrpBPGaM/Gm6kMFtiG7c+5IvQKlXi/t+9x9DrrCh29cww5TSP9YdVaBcnLDs5fQ==", + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz", + "integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==", "dev": true, "license": "MIT", "engines": { From da6ad84646acdda510cc19ba332f8e96cba70d4f Mon Sep 17 00:00:00 2001 From: Irina Nazarova Date: Sat, 20 Jun 2026 22:07:53 -0700 Subject: [PATCH 03/10] socketioxide: fix scaffold against latest crate, drop fictional CSR Maintainer + API check turned up two scaffold bugs: 1. Wrong version pin. Cargo.toml had socketioxide = "0.16"; latest stable is 0.18.3 (Apr 2026, same author actively shipping engineio hardening fixes on the day this branch landed). Bumped to ^0.18, features ['v4', 'tracing'] for the Socket.io v4 wire protocol and structured logs. 2. The 'state-recovery' feature I specified does not exist. socketioxide 0.18.3 feature list is v4 / msgpack / tracing / extensions / state / __test_harness. The 'state' feature is for application-shared state (with_state), not session resume. CSR is not documented in the README, examples, or feature flags. Dropped: - socketioxideCsr TARGETS entry - All -csr manifest entries (latency-csr, jitter-csr) - The two FIXME blocks in main.rs that pretended to enable CSR - The recovered counter and /stats-csr endpoint - The SOCKETIO_CSR env var What's left is honest at-most-once socketioxide: tested with the same disruption shape as default Socket.io and uWS. The architectural prediction is that it lands in the at-most-once band (~85% delivery under jitter, in-process WS dies with the app on deploy), which would confirm the page's claim that those properties are about deployment topology rather than runtime language. CSR is now an open question to the library author in docs/socketioxide-comparison.md (does the crate ship CSR? Is it on the roadmap?). If yes, we add the variant back. If no, the comparison is what it is. --- backend/src/bench/tests-manifest.ts | 44 +++--------- docs/socketioxide-comparison.md | 102 +++++++++++++++++----------- socketioxide/Cargo.toml | 10 +-- socketioxide/src/main.rs | 39 +++-------- 4 files changed, 85 insertions(+), 110 deletions(-) diff --git a/backend/src/bench/tests-manifest.ts b/backend/src/bench/tests-manifest.ts index 4a09baf..9397d0a 100644 --- a/backend/src/bench/tests-manifest.ts +++ b/backend/src/bench/tests-manifest.ts @@ -95,11 +95,12 @@ const TARGETS = { "http://anycable-go-pro.railway.internal:8080/_broadcast", // socketioxide is the Rust implementation of the Socket.io protocol. // Wire-compatible with socket.io-client, so the existing bench-runner - // `bench-jitter-socketio`/`-csr` endpoints work as-is with - // `?serverUrl=`. Default service ships SOCKETIO_CSR=0; the CSR - // variant deploys the same image with SOCKETIO_CSR=1 set at boot. + // bench-jitter-socketio / bench-idle-socketio / bench-avalanche-socketio + // endpoints work with ?serverUrl=. No CSR variant yet — the library + // doesn't appear to ship Connection State Recovery as of 0.18.3. + // See docs/socketioxide-comparison.md for the open question to the + // library author. socketioxide: "http://socketioxide-server.railway.internal:3000", - socketioxideCsr: "http://socketioxide-server-csr.railway.internal:3000", }; // Common knobs reused across tests. Keep these explicit so the manifest @@ -230,24 +231,6 @@ export const tests: TestSpec[] = [ params: { n: 10000, ...LATENCY_10K, serverUrl: TARGETS.socketioxide }, baseline: {}, }, - { - id: "latency-socketioxide-csr-1k", - description: "Roundtrip latency, socketioxide + CSR, 1K subs", - category: "latency", - endpoint: "bench-jitter-socketio-csr", - mode: "sync", - params: { n: 1000, ...LATENCY_1K, serverUrl: TARGETS.socketioxideCsr }, - baseline: {}, - }, - { - id: "latency-socketioxide-csr-10k", - description: "Roundtrip latency, socketioxide + CSR, 10K subs", - category: "latency", - endpoint: "bench-jitter-socketio-csr", - mode: "sync", - params: { n: 10000, ...LATENCY_10K, serverUrl: TARGETS.socketioxideCsr }, - baseline: {}, - }, // ------------------------------------------------------------------------- // Reliability (jitter under WiFi-drop pattern) @@ -311,27 +294,18 @@ export const tests: TestSpec[] = [ params: { n: 10000, ...JITTER_10K, cableUrl: TARGETS.anycablePro, broadcastUrl: TARGETS.anycableProBroadcast, samplesCap: 5000 }, baseline: { deliveryRatePct: 100, lostDeliveries: 0, "latencyRawMs.p95": 4100, "latencyRawMs.p99": 6200 }, }, - // socketioxide jitter rows. Default (no CSR) should land in the - // at-most-once band with Socket.io and uWS; the CSR row tests whether - // the Rust impl delivers 100% with a different replay-tail shape. + // socketioxide jitter row. Expected to land in the at-most-once band + // with default Socket.io and uWS, since socketioxide doesn't appear to + // ship CSR. Confirms the architectural claim across runtimes. { id: "jitter-socketioxide-10k", - description: "Reliability under WiFi jitter, socketioxide default, 10K", + description: "Reliability under WiFi jitter, socketioxide (Rust), 10K", category: "jitter", endpoint: "bench-jitter-socketio", mode: "async", params: { n: 10000, ...JITTER_10K, serverUrl: TARGETS.socketioxide, samplesCap: 5000 }, baseline: {}, }, - { - id: "jitter-socketioxide-csr-10k", - description: "Reliability under WiFi jitter, socketioxide + CSR, 10K", - category: "jitter", - endpoint: "bench-jitter-socketio-csr", - mode: "async", - params: { n: 10000, ...JITTER_10K, serverUrl: TARGETS.socketioxideCsr, samplesCap: 5000 }, - baseline: {}, - }, // ------------------------------------------------------------------------- // Whispers (1K × 10 rooms, 100 peers/room) diff --git a/docs/socketioxide-comparison.md b/docs/socketioxide-comparison.md index 6bfed16..c62fc9e 100644 --- a/docs/socketioxide-comparison.md +++ b/docs/socketioxide-comparison.md @@ -13,21 +13,43 @@ numbers look like once we have them. - **Server scaffold:** `socketioxide/` in this repo. Cargo project plus Dockerfile that mirrors the shape of `backend/src/socketio/server.ts`: - `/health`, `/stats`, `/_broadcast`, `/publish-local`. Needs a compile + - review pass by someone with current `socketioxide` API expertise; the - Connection State Recovery section in `socketioxide/src/main.rs` is the - rough spot (the crate's CSR API has moved across versions). -- **Bench-runner endpoints:** none new. The Rust server speaks the Socket.io - wire protocol, so the existing `bench-jitter-socketio`, - `bench-jitter-socketio-csr`, `bench-idle-socketio`, and - `bench-avalanche-socketio` endpoints all accept it via `?serverUrl=...`. + `/health`, `/stats`, `/_broadcast`, `/publish-local`. +- **Crate pinned to `socketioxide = 0.18`** (latest stable, April 2026). + Features: `v4` for the Socket.io v4 wire protocol the bench-runner + speaks, `tracing` for structured logs. Maintainer was actively pushing + engineio hardening fixes on the day this branch landed; bump the pin + in tandem with the next tagged release if those fixes have shipped by + the time you deploy. +- **Bench-runner endpoints:** none new. The Rust server speaks the + Socket.io wire protocol, so the existing `bench-jitter-socketio`, + `bench-idle-socketio`, and `bench-avalanche-socketio` endpoints all + accept it via `?serverUrl=...`. - **Manifest entries:** added under each rubric in - `backend/src/bench/tests-manifest.ts`. Baselines are empty until the first - run lands. -- **Railway services:** two services to deploy from this directory: - `socketioxide-server` (default mode) and `socketioxide-server-csr` - (`SOCKETIO_CSR=1` set at boot). Both on the same hardware tier as the - other Socket.io targets so the comparison stays apples-to-apples. + `backend/src/bench/tests-manifest.ts`. Baselines are empty until the + first run lands. +- **Railway service:** one to deploy from this directory: + `socketioxide-server`. Same hardware tier as the other Socket.io + targets so the comparison stays apples-to-apples. The CSR variant is + deferred (see open questions). + +## Open question that gates the CSR comparison + +The Socket.io + CSR row of the page tests `connectionStateRecovery`: +on disconnect, the server stashes packet state and a per-socket pid; on +reconnect, the client sends pid + last-offset and the server replays. +Node Socket.io supports this since 4.6. + +**Does socketioxide ship CSR?** As of `0.18.3` it does not appear to. +The crate's `Cargo.toml` features list (`v4`, `msgpack`, `tracing`, +`extensions`, `state`, `__test_harness`) has no CSR-shaped flag; the +README and examples don't mention session resume; the `state` feature +is for application-level shared state, not session state. + +So we deferred the CSR variant. If the library author confirms CSR is +on the roadmap (or already present under a different name we missed), +we'll add `socketioxide-server-csr` as a second service and the +`-csr` manifest entries. Until then, socketioxide is tested only in +its at-most-once mode and reported next to default Socket.io / uWS. ## Why this strengthens the comparison @@ -52,38 +74,40 @@ Either way, the result is a stronger page, not a weaker one. ## Rubrics + manifest IDs -| Rubric | Default | + CSR | -|---|---|---| -| Latency 1K | `latency-socketioxide-1k` | `latency-socketioxide-csr-1k` | -| Latency 10K | `latency-socketioxide-10k` | `latency-socketioxide-csr-10k` | -| Jitter 10K | `jitter-socketioxide-10k` | `jitter-socketioxide-csr-10k` | -| Idle 1M target | `idle-socketioxide` | n/a | -| Avalanche 5K | `avalanche-socketioxide-5k` | n/a | -| Avalanche 10K | `avalanche-socketioxide-10k` | n/a | -| Avalanche 20K | `avalanche-socketioxide-20k` | n/a | +| Rubric | socketioxide (at-most-once) | +|---|---| +| Latency 1K | `latency-socketioxide-1k` | +| Latency 10K | `latency-socketioxide-10k` | +| Jitter 10K | `jitter-socketioxide-10k` | +| Idle 1M target | `idle-socketioxide` | +| Avalanche 5K | `avalanche-socketioxide-5k` | +| Avalanche 10K | `avalanche-socketioxide-10k` | +| Avalanche 20K | `avalanche-socketioxide-20k` | Whispers and throughput entries can be added the same way once we have -the latency + jitter numbers and we know the server config holds up. +the latency + jitter numbers and the server config holds up. ## Open questions for the library author Tagged in the GitHub issue: -1. **CSR API.** The `socketioxide/src/main.rs` skeleton has the - Connection State Recovery builder call commented out with a FIXME. - Confirm the right shape for the pinned crate version (or bump - the version, whichever is cleaner). -2. **Recovered flag.** When a CSR resume succeeds, we need to bump the - `recovered` counter so `/stats-csr` reports it. The right accessor - on the socket varies across crate versions. -3. **Production-shaped publisher.** Our `/_broadcast` handler calls - `io.to(stream).emit(...).await`. That's the natural shape but it - might not match the recommended pattern for socketioxide in - production. Open to a PR that swaps it. -4. **Default config knobs.** Anything we should tune for the comparison - to be fair? Compression, ping intervals, buffer sizes. Defaults - here match the Node Socket.io server's bench config; happy to take - a config patch. +1. **Connection State Recovery roadmap.** Does socketioxide support + server-side session resume (pid + last-offset replay) today? If yes, + how is it enabled (feature flag, builder config)? If no, is it on + the roadmap? Answer decides whether we add a `-csr` variant or skip + the CSR rubric for this server. +2. **Production-shaped publisher.** Our `/_broadcast` handler calls + `io.to(stream).emit(...).await` on every POST. That's the natural + shape but might not match the recommended pattern for socketioxide + at high broadcast rates. Open to a PR that swaps it. +3. **Default config knobs.** Anything we should tune for the + comparison to be fair? Compression, ping intervals, buffer sizes. + Defaults here match the Node Socket.io server's bench config; happy + to take a config patch. +4. **Engineio hardening.** Several `fix(engineio): ... malicious / + malformed packet` commits landed on 2026-06-20. Should we pin to a + commit past those rather than the `0.18.3` tag? Or wait for the + next release that bundles them? ## Results diff --git a/socketioxide/Cargo.toml b/socketioxide/Cargo.toml index 0a327dc..b050c31 100644 --- a/socketioxide/Cargo.toml +++ b/socketioxide/Cargo.toml @@ -4,10 +4,11 @@ version = "0.1.0" edition = "2021" [dependencies] -# Pin to a recent socketioxide; the API for CSR / connection state -# recovery has shifted across versions, so the library author is the -# right person to bump this if needed. -socketioxide = { version = "0.16", features = ["state-recovery"] } +# Pinned to the current stable release as of 2026-06. The crate is on a +# steady ~monthly cadence; bump in tandem with engineioxide when a new +# semver-minor lands. v4 enables the Socket.io v4 wire protocol that +# our bench-runner's socket.io-client driver speaks. +socketioxide = { version = "0.18", features = ["v4", "tracing"] } axum = "0.7" tokio = { version = "1", features = ["full"] } tower = "0.5" @@ -15,7 +16,6 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" tracing = "0.1" tracing-subscriber = "0.3" -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } [profile.release] opt-level = 3 diff --git a/socketioxide/src/main.rs b/socketioxide/src/main.rs index b28c7f3..2e1ff83 100644 --- a/socketioxide/src/main.rs +++ b/socketioxide/src/main.rs @@ -5,7 +5,6 @@ // Endpoints: // GET /health service liveness probe // GET /stats { connections } -// GET /stats-csr { connections, recovered } (CSR mode) // POST /_broadcast { stream, data } -> io.to(stream).emit("message", data) // POST /publish-local in-process publish loop, mirrors the Node server's // /publish-local for the "publisher inside the WS @@ -13,13 +12,13 @@ // // Env: // PORT default 3000 -// SOCKETIO_CSR "1" enables Connection State Recovery // -// FIXME(socketioxide author): the CSR feature flag and the with_state_recovery -// builder call below should be checked against the version pinned in -// Cargo.toml. The version of socketioxide we target has moved around the -// state-recovery API; this is the best-effort shape and very likely needs -// adjustment when first compiled. See the open issue for review. +// CSR (Connection State Recovery) is intentionally not implemented here. +// socketioxide 0.18.3 doesn't appear to ship server-side session resume +// (no CSR-shaped feature flag, no mention in README or examples). If the +// library adds it, we'll wire SOCKETIO_CSR=1 the same way the Node +// server does. See docs/socketioxide-comparison.md for the open +// question to the library author. use std::env; use std::sync::atomic::{AtomicU64, Ordering}; @@ -43,7 +42,6 @@ use tracing::info; struct AppState { io: SocketIo, connections: Arc, - recovered: Arc, } #[derive(Deserialize)] @@ -55,7 +53,6 @@ struct BroadcastBody { #[derive(Serialize)] struct Stats { connections: u64, - recovered: u64, } #[derive(Deserialize)] @@ -73,7 +70,6 @@ async fn health() -> Json { async fn stats(State(state): State) -> Json { Json(Stats { connections: state.connections.load(Ordering::Relaxed), - recovered: state.recovered.load(Ordering::Relaxed), }) } @@ -141,34 +137,16 @@ async fn main() { .and_then(|v| v.parse().ok()) .unwrap_or(3000); - let enable_csr = env::var("SOCKETIO_CSR") - .map(|v| v == "1") - .unwrap_or(false); - - // FIXME(socketioxide author): adjust the builder call below to enable - // Connection State Recovery in the exact way the pinned crate version - // expects. The shape here is illustrative. - let builder = SocketIo::builder(); - // if enable_csr { - // builder = builder.with_state_recovery( - // socketioxide::state_recovery::StateRecoveryConfig::default(), - // ); - // } - let (layer, io) = builder.build_layer(); + let (layer, io) = SocketIo::new_layer(); let state = AppState { io: io.clone(), connections: Arc::new(AtomicU64::new(0)), - recovered: Arc::new(AtomicU64::new(0)), }; let conns = state.connections.clone(); - let recovered = state.recovered.clone(); io.ns("/", move |socket: SocketRef| { conns.fetch_add(1, Ordering::Relaxed); - // FIXME(socketioxide author): when CSR is enabled, the recovered - // flag on the socket should be checked here and `recovered` - // incremented. The exact accessor varies by version. socket.on("join", |socket: SocketRef, room: Value| async move { if let Some(name) = room.as_str() { @@ -196,7 +174,6 @@ async fn main() { let app = Router::new() .route("/health", get(health)) .route("/stats", get(stats)) - .route("/stats-csr", get(stats)) .route("/_broadcast", post(broadcast)) .route("/publish-local", post(publish_local)) .layer(layer) @@ -204,6 +181,6 @@ async fn main() { let addr = format!("0.0.0.0:{}", port); let listener = TcpListener::bind(&addr).await.expect("bind"); - info!(addr = %addr, csr = enable_csr, "socketioxide bench server listening"); + info!(addr = %addr, "socketioxide bench server listening"); axum::serve(listener, app).await.expect("serve"); } From 216e4e439481c63156ae01552d96e69adc6c056f Mon Sep 17 00:00:00 2001 From: Irina Nazarova Date: Mon, 22 Jun 2026 18:25:38 -0700 Subject: [PATCH 04/10] socketioxide: compile against real 0.18.4 API + first local run vs AnyCable The scaffold now compiles and runs. Fixing it against the released crate turned up three API mismatches from my first guess: - Connect handlers must be async (io.ns('/', on_connect) where on_connect is an async fn), not sync closures. - State (the connection counter) goes through .with_state(Arc) + the State extractor, gated behind the 'state' feature flag, which I'd left out. Handlers read it as IoState. - emit takes &data and is .await-ed; room handlers use Data extractors, not positional Value. Pinned 0.18 resolves to 0.18.4; release build is clean. First real numbers, local head-to-head with anycable-go 1.6.14 as a same-window control (its normal shape confirms the environment was sound). 200 clients, per-message HTTP publish for both: Latency (jitter off): socketioxide 100% / p99 18ms, AnyCable 100% / p99 22ms. Comparable. Jitter (TCP drop /15s): socketioxide 91.6% delivered (at-most-once, no replay, fast on what it sends), AnyCable 100% (replay, multi-second tail). socketioxide lands in the at-most-once band with default Socket.io and uWS: the delivery gap is the protocol (replay vs none), not the runtime language. Confirms the page's architectural claim across a fourth impl. Results table + reproducer in docs/socketioxide-comparison.md; raw JSON force-added at backend/results/socketioxide-local-2026-06-23.json. Comparison page untouched, as requested. Railway-scale rows (10K, idle 1M, avalanche) still pending a deploy; FILTER=socketioxide,anycable runs the new rows with AnyCable as the canary. --- README.md | 2 +- .../socketioxide-local-2026-06-23.json | 23 ++++ docs/socketioxide-comparison.md | 87 +++++++++++-- socketioxide/Cargo.toml | 2 +- socketioxide/src/main.rs | 117 ++++++++++-------- 5 files changed, 170 insertions(+), 61 deletions(-) create mode 100644 backend/results/socketioxide-local-2026-06-23.json diff --git a/README.md b/README.md index 5dc1288..f88dad2 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ The repo behind [anycable.io/compare/nodejs-websocket](https://anycable.io/compa Setups under test: default Socket.io, Socket.io + Connection State Recovery, uWebSockets.js, AnyCable OSS, AnyCable Pro. -Additional target on request: [socketioxide](https://github.com/totodore/socketioxide) (Rust Socket.io server). Scaffolded in `socketioxide/`; results tracked in [`docs/socketioxide-comparison.md`](./docs/socketioxide-comparison.md). +Additional target on request: [socketioxide](https://github.com/totodore/socketioxide) (Rust Socket.io server), in `socketioxide/`. First local head-to-head vs AnyCable is in [`docs/socketioxide-comparison.md`](./docs/socketioxide-comparison.md): same at-most-once shape as default Socket.io (91.6% delivered under jitter vs AnyCable's 100%), comparable steady-state latency. Railway-scale rows pending. Methodology, traps, and the bugs we caught in our own setup: [`docs/methodology.md`](./docs/methodology.md). Below: the numbers and how to rerun them. diff --git a/backend/results/socketioxide-local-2026-06-23.json b/backend/results/socketioxide-local-2026-06-23.json new file mode 100644 index 0000000..2a9ee81 --- /dev/null +++ b/backend/results/socketioxide-local-2026-06-23.json @@ -0,0 +1,23 @@ +{ + "run": "socketioxide vs anycable-go, local head-to-head (control canary)", + "date": "2026-06-23T01:23:00Z", + "environment": { + "host": "local single machine (macOS, M-series)", + "scale": "small (200 clients) — NOT Railway 10K headline scale", + "note": "AnyCable run in the same window as a control; its expected shape (100% delivery, multi-second replay tail under jitter) confirms the environment was sound for the socketioxide measurement.", + "socketioxide": "0.18.4 (rustc 1.95.0, release build, features v4+tracing+state)", + "anycable_go": "1.6.14 (--broker=memory --presets=broker)", + "node": "v26.0.0", + "publish_path": "per-message HTTP POST to /_broadcast (symmetric for both)" + }, + "latency": { + "params": { "n": 200, "totalMessages": 100, "intervalMs": 200, "durationSec": 60, "jitter": "disabled" }, + "socketioxide": { "deliveryRatePct": 100, "lost": 0, "p50": 4, "p95": 12, "p99": 18, "max": 27 }, + "anycable": { "deliveryRatePct": 100, "lost": 0, "p50": 5, "p95": 12, "p99": 22, "max": 29 } + }, + "jitter": { + "params": { "n": 200, "totalMessages": 60, "intervalMs": 500, "durationSec": 90, "jitterIntervalSec": 15, "jitterDurationMs": 1000 }, + "socketioxide": { "deliveryRatePct": 91.61, "lost": 928, "received": 10993, "jitterEvents": 872, "p50": 4, "p95": 12, "p99": 16, "max": 26, "protocol": "at-most-once (no replay)" }, + "anycable": { "deliveryRatePct": 100, "lost": 0, "received": 12000, "jitterEvents": 931, "p50": 7, "p95": 3621, "p99": 5623, "max": 8455, "protocol": "replay (history on reconnect)" } + } +} diff --git a/docs/socketioxide-comparison.md b/docs/socketioxide-comparison.md index c62fc9e..637b665 100644 --- a/docs/socketioxide-comparison.md +++ b/docs/socketioxide-comparison.md @@ -13,13 +13,15 @@ numbers look like once we have them. - **Server scaffold:** `socketioxide/` in this repo. Cargo project plus Dockerfile that mirrors the shape of `backend/src/socketio/server.ts`: - `/health`, `/stats`, `/_broadcast`, `/publish-local`. -- **Crate pinned to `socketioxide = 0.18`** (latest stable, April 2026). + `/health`, `/stats`, `/_broadcast`, `/publish-local`. **Compiles and + runs** (release build against `socketioxide 0.18.4`); validated locally + against the bench-runner's socket.io-client driver (see Results). +- **Crate pinned to `socketioxide = 0.18`**, resolves to `0.18.4`. Features: `v4` for the Socket.io v4 wire protocol the bench-runner - speaks, `tracing` for structured logs. Maintainer was actively pushing - engineio hardening fixes on the day this branch landed; bump the pin - in tandem with the next tagged release if those fixes have shipped by - the time you deploy. + speaks, `tracing` for logs, `state` for the connection counter behind + `/stats`. Maintainer was actively pushing engineio hardening fixes on + the day this branch landed; bump the pin in tandem with the next tagged + release if those fixes have shipped by the time you deploy at scale. - **Bench-runner endpoints:** none new. The Rust server speaks the Socket.io wire protocol, so the existing `bench-jitter-socketio`, `bench-idle-socketio`, and `bench-avalanche-socketio` endpoints all @@ -111,18 +113,83 @@ Tagged in the GitHub issue: ## Results -*Run pending. Numbers will land here once the services are deployed and the -manifest is rerun against them.* +### Local head-to-head, socketioxide vs AnyCable (2026-06-23) -To run the socketioxide-only subset: +First real run. socketioxide `0.18.4` (release build) against `anycable-go` +1.6.14, both on one machine, 200 clients, per-message HTTP publishing for +both. AnyCable runs in the same window as a control: its expected shape +(100% delivery, multi-second replay tail under jitter) confirms the +environment was sound, so the socketioxide numbers aren't an artifact of a +bad local moment. This is small-scale local, not the Railway 10K headline +setup; treat it as "the harness works and the architectural shape holds", +not as a published page number. + +**Latency (jitter disabled), 200 clients, 100 messages at 5/sec:** + +| | Delivery | p50 | p95 | p99 | max | +|---|---|---|---|---|---| +| socketioxide | 100% | 4 ms | 12 ms | 18 ms | 27 ms | +| AnyCable (control) | 100% | 5 ms | 12 ms | 22 ms | 29 ms | + +Roundtrip latency is the same order on both. Nothing separates a Rust +Socket.io server from AnyCable when the network is steady. + +**Delivery under jitter, 200 clients, TCP force-close every ~15 s:** + +| | Delivery | Lost | p50 | p95 | p99 | max | +|---|---|---|---|---|---|---| +| socketioxide | **91.6%** | 928 | 4 ms | 12 ms | 16 ms | 26 ms | +| AnyCable (control) | **100%** | 0 | 7 ms | 3.6 s | 5.6 s | 8.5 s | + +This is the architectural result the page argues for, reproduced across a +fourth runtime. socketioxide is at-most-once: it has no replay, so the +broadcasts that land during a client's offline window are gone (~8% lost +here). The messages it *does* deliver are fast. AnyCable delivers 100% +because it replays the missed range on reconnect, which is what the +multi-second p95/p99 tail is: late-but-delivered messages. The Rust +implementation lands in the same at-most-once band as default Socket.io +and uWS. The delivery gap is about the protocol (replay vs none), not the +language. + +Raw numbers: `backend/results/socketioxide-local-2026-06-23.json`. + +### Not yet run (needs Railway) + +Latency 10K, jitter 10K, idle 1M, and the avalanche escalation need the +Railway services and the 50-shard bench-runner fleet. Deploy +`socketioxide-server` from `socketioxide/` (same hardware tier as the +other Socket.io targets), then run the socketioxide subset with AnyCable +alongside as the control: ```bash cd backend BENCH_RUNNER_URL=https://bench-runner-production.up.railway.app \ BENCH_RUNNER_TOKEN= \ -FILTER=socketioxide \ +FILTER=socketioxide,anycable \ npm run bench:rebaseline ``` +`FILTER=socketioxide,anycable` runs only the new Rust rows plus the +AnyCable rows as the same-window canary, skipping the rest of the matrix. Multi-shard idle and avalanche entries gate behind `INCLUDE_IDLE=1` and `INCLUDE_AVALANCHE=1` as usual. + +### Reproduce the local run + +```bash +# Terminal 1 — socketioxide +cd socketioxide && cargo run --release # :3000 + +# Terminal 2 — anycable-go +anycable-go --port 8080 --broker=memory --presets=broker --public + +# Terminal 3 — jitter, both, same window +cd backend +SOCKETIO_URL=http://localhost:3000 NUM_CLIENTS=200 DURATION=90 \ + TOTAL_MESSAGES=60 INTERVAL_MS=500 JITTER_INTERVAL=15 JITTER_DURATION=1000 \ + npm run bench:jitter:socketio +ANYCABLE_URL=ws://localhost:8080/cable BROADCAST_URL=http://localhost:8090/_broadcast \ + NUM_CLIENTS=200 DURATION=90 \ + TOTAL_MESSAGES=60 INTERVAL_MS=500 JITTER_INTERVAL=15 JITTER_DURATION=1000 \ + npm run bench:jitter:anycable +``` diff --git a/socketioxide/Cargo.toml b/socketioxide/Cargo.toml index b050c31..d0ae99b 100644 --- a/socketioxide/Cargo.toml +++ b/socketioxide/Cargo.toml @@ -8,7 +8,7 @@ edition = "2021" # steady ~monthly cadence; bump in tandem with engineioxide when a new # semver-minor lands. v4 enables the Socket.io v4 wire protocol that # our bench-runner's socket.io-client driver speaks. -socketioxide = { version = "0.18", features = ["v4", "tracing"] } +socketioxide = { version = "0.18", features = ["v4", "tracing", "state"] } axum = "0.7" tokio = { version = "1", features = ["full"] } tower = "0.5" diff --git a/socketioxide/src/main.rs b/socketioxide/src/main.rs index 2e1ff83..219486d 100644 --- a/socketioxide/src/main.rs +++ b/socketioxide/src/main.rs @@ -14,11 +14,10 @@ // PORT default 3000 // // CSR (Connection State Recovery) is intentionally not implemented here. -// socketioxide 0.18.3 doesn't appear to ship server-side session resume -// (no CSR-shaped feature flag, no mention in README or examples). If the -// library adds it, we'll wire SOCKETIO_CSR=1 the same way the Node -// server does. See docs/socketioxide-comparison.md for the open -// question to the library author. +// socketioxide 0.18 doesn't ship server-side session resume (no CSR-shaped +// feature flag, no mention in README or examples). If the library adds it, +// we'll wire it the same way the Node server does. See +// docs/socketioxide-comparison.md for the open question to the library author. use std::env; use std::sync::atomic::{AtomicU64, Ordering}; @@ -26,7 +25,7 @@ use std::sync::Arc; use std::time::Duration; use axum::{ - extract::{Query, State}, + extract::{Query, State as AxumState}, http::StatusCode, response::Json, routing::{get, post}, @@ -34,14 +33,25 @@ use axum::{ }; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; -use socketioxide::{extract::SocketRef, SocketIo}; +use socketioxide::{ + extract::{Data, SocketRef, State as IoState}, + SocketIo, +}; use tokio::net::TcpListener; use tracing::info; +// Shared connection counter. Registered as socketioxide state via +// `.with_state(Arc)` (read in handlers through the State +// extractor) and cloned into the axum router state so GET /stats reads +// the same Arc. +type ConnCounter = Arc; + +// Router state for the HTTP endpoints: the io handle (to broadcast) plus +// the same counter Arc. #[derive(Clone)] -struct AppState { +struct HttpState { io: SocketIo, - connections: Arc, + connections: ConnCounter, } #[derive(Deserialize)] @@ -63,33 +73,63 @@ struct PublishLocalQuery { delay: Option, } +// --- socketioxide handlers --------------------------------------------------- + +async fn on_connect(s: SocketRef, connections: IoState) { + connections.fetch_add(1, Ordering::Relaxed); + + // Client emits `join` with the stream name (a string). + s.on("join", |s: SocketRef, Data::(room)| async move { + let _ = s.join(room); + }); + + // Whisper: client emits ("whisper", room, payload). socketioxide + // delivers multiple emit args as a tuple. Forward to everyone else + // in the room. Out of scope for the jitter/latency/idle/avalanche + // tests but kept for parity with the Node server. + s.on( + "whisper", + |s: SocketRef, Data::<(String, Value)>((room, payload))| async move { + let _ = s.to(room).emit("whisper", &payload).await; + }, + ); + + s.on_disconnect(on_disconnect); +} + +async fn on_disconnect(connections: IoState) { + connections.fetch_sub(1, Ordering::Relaxed); +} + +// --- HTTP handlers ----------------------------------------------------------- + async fn health() -> Json { Json(json!({ "status": "ok", "mode": "socketioxide" })) } -async fn stats(State(state): State) -> Json { +async fn stats(AxumState(state): AxumState) -> Json { Json(Stats { connections: state.connections.load(Ordering::Relaxed), }) } async fn broadcast( - State(state): State, + AxumState(state): AxumState, Json(body): Json, ) -> (StatusCode, Json) { // The Node server accepts `data` as either a JSON object or a string - // containing JSON (the bench-runner uses the string form). Mirror that - // behaviour so the bench-runner's existing driver works unchanged. + // containing JSON (the bench-runner sends the string form). Mirror that + // so the bench-runner's existing driver works unchanged. let payload = match body.data { Value::String(s) => serde_json::from_str::(&s).unwrap_or(Value::String(s)), other => other, }; - let _ = state.io.to(body.stream).emit("message", payload).await; + let _ = state.io.to(body.stream).emit("message", &payload).await; (StatusCode::OK, Json(json!({ "ok": true }))) } async fn publish_local( - State(state): State, + AxumState(state): AxumState, Query(q): Query, ) -> Json { let total = q.total.unwrap_or(120); @@ -104,9 +144,9 @@ async fn publish_local( tokio::time::sleep(Duration::from_secs(delay)).await; } for seq in 1..=total { - let sent_at = chrono_millis(); + let sent_at = unix_millis(); let msg = json!({ "seq": seq, "sentAt": sent_at, "text": format!("msg_{}", seq) }); - let _ = io.to(stream_for_task.clone()).emit("message", msg).await; + let _ = io.to(stream_for_task.clone()).emit("message", &msg).await; tokio::time::sleep(Duration::from_millis(interval)).await; } }); @@ -120,7 +160,7 @@ async fn publish_local( })) } -fn chrono_millis() -> i64 { +fn unix_millis() -> i64 { use std::time::{SystemTime, UNIX_EPOCH}; SystemTime::now() .duration_since(UNIX_EPOCH) @@ -137,47 +177,26 @@ async fn main() { .and_then(|v| v.parse().ok()) .unwrap_or(3000); - let (layer, io) = SocketIo::new_layer(); + let connections: ConnCounter = Arc::new(AtomicU64::new(0)); + + let (layer, io) = SocketIo::builder() + .with_state(connections.clone()) + .build_layer(); - let state = AppState { + io.ns("/", on_connect); + + let http_state = HttpState { io: io.clone(), - connections: Arc::new(AtomicU64::new(0)), + connections: connections.clone(), }; - let conns = state.connections.clone(); - io.ns("/", move |socket: SocketRef| { - conns.fetch_add(1, Ordering::Relaxed); - - socket.on("join", |socket: SocketRef, room: Value| async move { - if let Some(name) = room.as_str() { - let _ = socket.join(name.to_string()); - } - }); - - socket.on("whisper", |socket: SocketRef, data: Value| async move { - if let (Some(room), Some(payload)) = - (data.get(0).and_then(|v| v.as_str()), data.get(1)) - { - let _ = socket - .to(room.to_string()) - .emit("whisper", payload.clone()) - .await; - } - }); - - let conn_dec = conns.clone(); - socket.on_disconnect(move || { - conn_dec.fetch_sub(1, Ordering::Relaxed); - }); - }); - let app = Router::new() .route("/health", get(health)) .route("/stats", get(stats)) .route("/_broadcast", post(broadcast)) .route("/publish-local", post(publish_local)) .layer(layer) - .with_state(state); + .with_state(http_state); let addr = format!("0.0.0.0:{}", port); let listener = TcpListener::bind(&addr).await.expect("bind"); From b0ec9cb883accaecd7a66b479d193ee09eb05718 Mon Sep 17 00:00:00 2001 From: Irina Nazarova Date: Mon, 22 Jun 2026 19:56:19 -0700 Subject: [PATCH 05/10] socketioxide: Railway deploy fixes (Rust 1.94+ base, IPv6 bind, lock) Three fixes found deploying to Railway: - Base image rust:1.83 was below socketioxide 0.18.4's MSRV (1.94) and too old for a transitive dep needing edition2024. Use rust:1-slim. - Commit Cargo.lock + build --locked so the image uses the exact deps resolved and tested locally (dropped the strip step; binutils absent in slim). - Bind [::] (IPv6 dual-stack), not 0.0.0.0. Railway's private network (*.railway.internal) routes over IPv6; 0.0.0.0 is unreachable internally. Verified: 20/20 clients connect via the bench-runner, 100% delivery, 42ms p99. PORT is pinned to 3000 on the service to match the manifest target. --- socketioxide/.gitignore | 1 - socketioxide/Cargo.lock | 1278 ++++++++++++++++++++++++++++++++++++++ socketioxide/Dockerfile | 19 +- socketioxide/src/main.rs | 5 +- 4 files changed, 1293 insertions(+), 10 deletions(-) create mode 100644 socketioxide/Cargo.lock diff --git a/socketioxide/.gitignore b/socketioxide/.gitignore index 2c96eb1..2f7896d 100644 --- a/socketioxide/.gitignore +++ b/socketioxide/.gitignore @@ -1,2 +1 @@ target/ -Cargo.lock diff --git a/socketioxide/Cargo.lock b/socketioxide/Cargo.lock new file mode 100644 index 0000000..d7241df --- /dev/null +++ b/socketioxide/Cargo.lock @@ -0,0 +1,1278 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit 0.7.3", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +dependencies = [ + "serde", +] + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "engineioxide" +version = "0.17.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411a8df7da5a9d80d6b1b75bb42a31bd2119c8e3927265536ed5c22fc6911058" +dependencies = [ + "base64", + "bytes", + "engineioxide-core", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "memchr", + "pin-project-lite", + "serde", + "serde_json", + "smallvec", + "thiserror", + "tokio", + "tokio-tungstenite", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "engineioxide-core" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68868b34254632364403d72a823927096ef5a1241bd039b544e5e1fa675e49e" +dependencies = [ + "base64", + "bytes", + "rand 0.10.1", + "serde", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generator" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc16584ff22b460a382b7feec54b23d2908d858152e5739a120b949293bd74e" +dependencies = [ + "cc", + "libc", + "log", + "rustversion", + "windows", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "loom" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff50ecb28bb86013e935fb6683ab1f6d3a20016f123c76fd4c27470076ac30f5" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "serde", + "serde_json", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "matchit" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8863b587001c1b9a8a4e36008cebc6b3612cb1226fe2de94858e06092687b608" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "socketioxide" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c067d6dd2b9745bcd16f3f2d00960f75fcce16e4abdf5db469854f45b37bda2" +dependencies = [ + "bytes", + "engineioxide", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "matchit 0.9.2", + "pin-project-lite", + "serde", + "socketioxide-core", + "socketioxide-parser-common", + "state", + "thiserror", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "socketioxide-bench-server" +version = "0.1.0" +dependencies = [ + "axum", + "serde", + "serde_json", + "socketioxide", + "tokio", + "tower", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "socketioxide-core" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b8ae8f85d9fad2966a7e6a49b47821f6b99454ec54fe53784f5f4172e28f70a" +dependencies = [ + "arbitrary", + "bytes", + "engineioxide-core", + "futures-core", + "serde", + "smallvec", + "thiserror", +] + +[[package]] +name = "socketioxide-parser-common" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ba9a856b0de7f665fe0d66fb7d60c74ad2e1810552c60a31f72e9aa6372cc02" +dependencies = [ + "bytes", + "itoa", + "serde", + "serde_json", + "socketioxide-core", +] + +[[package]] +name = "state" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b8c4a4445d81357df8b1a650d0d0d6fbbbfe99d064aa5e02f3e4022061476d8" +dependencies = [ + "loom", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.4", + "sha1", + "thiserror", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "windows" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/socketioxide/Dockerfile b/socketioxide/Dockerfile index 2ea661a..2a589aa 100644 --- a/socketioxide/Dockerfile +++ b/socketioxide/Dockerfile @@ -1,21 +1,24 @@ # Build the socketioxide bench server. # -# Same shape as backend/Dockerfile: builder stage produces a static binary, -# runtime stage is a thin slim image. Build the image from the repo root: +# Same shape as backend/Dockerfile: builder stage produces a binary, +# runtime stage is a thin slim image. Build the image from this directory: # -# docker build -f socketioxide/Dockerfile -t socketioxide-server . +# docker build -t socketioxide-server . # -# Or via railway.toml deploy from this directory. +# Or via railway.toml deploy from this directory (--path-as-root). -FROM rust:1.83-slim AS builder +# socketioxide 0.18.4 / engineioxide 0.17.5 set MSRV 1.94. Use the latest +# stable 1.x so the image clears it (built locally on 1.95). +FROM rust:1-slim-bookworm AS builder WORKDIR /src RUN apt-get update \ && apt-get install -y --no-install-recommends pkg-config libssl-dev \ && rm -rf /var/lib/apt/lists/* -COPY Cargo.toml ./ +# Copy the lock too so the image builds the exact dependency versions we +# resolved and tested locally (--locked fails the build if they drift). +COPY Cargo.toml Cargo.lock ./ COPY src/ ./src/ -RUN cargo build --release \ - && strip target/release/socketioxide-bench-server +RUN cargo build --release --locked FROM debian:bookworm-slim RUN apt-get update \ diff --git a/socketioxide/src/main.rs b/socketioxide/src/main.rs index 219486d..b64e172 100644 --- a/socketioxide/src/main.rs +++ b/socketioxide/src/main.rs @@ -198,7 +198,10 @@ async fn main() { .layer(layer) .with_state(http_state); - let addr = format!("0.0.0.0:{}", port); + // Bind IPv6 any (`[::]`), which is dual-stack on Linux and, crucially, + // is what Railway's private network (*.railway.internal) routes over. + // Binding 0.0.0.0 (IPv4-only) makes the service unreachable internally. + let addr = format!("[::]:{}", port); let listener = TcpListener::bind(&addr).await.expect("bind"); info!(addr = %addr, "socketioxide bench server listening"); axum::serve(listener, app).await.expect("serve"); From 4a4cf8dc0531a2ea257ea56b8de15c1f3f500919 Mon Sep 17 00:00:00 2001 From: Irina Nazarova Date: Mon, 22 Jun 2026 20:27:31 -0700 Subject: [PATCH 06/10] socketioxide: Railway 10K head-to-head vs AnyCable + report Phase 1 on real infra. Deployed socketioxide-server to the bench project, woke anycable-go OSS as the same-window canary, drove both from the Railway bench-runner over the internal network. Latency (jitter off): comparable to AnyCable at 1K and 10K, both 100% delivery (socketioxide 289/972ms p50/p99 at 10K vs AnyCable 232/731ms). Jitter delivery, bracketed across scale: 200 local: 91.6% 1K Railway: 89.4% 10K Railway: 40.6% then 32.7% socketioxide is at-most-once: it sits in the band with default Socket.io (~85%) and uWS (~87%) up to 1K, then collapses under the 10K reconnect storm. Two independent 10K runs (41%, 33%) confirm it; not a crash (0 connect failures, 10K/10K connect), not Railway noise (AnyCable held 100% in the same windows). The Rust runtime does not rescue the in-process at-most-once architecture at scale; AnyCable holds 100% because the WS layer is a separate process that the deploy/storm never restarts and replay recovers the offline-window gap. Comparison page untouched. Idle 1M + avalanche deferred to phase 2 (needs the 50-shard fleet). All phase-1 services torn back down to offline after the run. Report: docs/socketioxide-comparison.md. Raw: backend/results/. --- README.md | 2 +- .../socketioxide-railway-2026-06-23.json | 17 ++++ docs/socketioxide-comparison.md | 85 ++++++++++++++++--- 3 files changed, 91 insertions(+), 13 deletions(-) create mode 100644 backend/results/socketioxide-railway-2026-06-23.json diff --git a/README.md b/README.md index f88dad2..ce27775 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ The repo behind [anycable.io/compare/nodejs-websocket](https://anycable.io/compa Setups under test: default Socket.io, Socket.io + Connection State Recovery, uWebSockets.js, AnyCable OSS, AnyCable Pro. -Additional target on request: [socketioxide](https://github.com/totodore/socketioxide) (Rust Socket.io server), in `socketioxide/`. First local head-to-head vs AnyCable is in [`docs/socketioxide-comparison.md`](./docs/socketioxide-comparison.md): same at-most-once shape as default Socket.io (91.6% delivered under jitter vs AnyCable's 100%), comparable steady-state latency. Railway-scale rows pending. +Additional target on request: [socketioxide](https://github.com/totodore/socketioxide) (Rust Socket.io server), in `socketioxide/`. Head-to-head vs AnyCable in [`docs/socketioxide-comparison.md`](./docs/socketioxide-comparison.md): comparable steady-state latency, and at-most-once delivery that sits in the Socket.io band up to 1K (89% under jitter) then collapses under the 10K reconnect storm (33-41% vs AnyCable's 100%). The Rust runtime does not rescue the in-process architecture at scale. Idle 1M + avalanche rows pending a phase 2. Methodology, traps, and the bugs we caught in our own setup: [`docs/methodology.md`](./docs/methodology.md). Below: the numbers and how to rerun them. diff --git a/backend/results/socketioxide-railway-2026-06-23.json b/backend/results/socketioxide-railway-2026-06-23.json new file mode 100644 index 0000000..2369c7c --- /dev/null +++ b/backend/results/socketioxide-railway-2026-06-23.json @@ -0,0 +1,17 @@ +{ + "run": "socketioxide vs anycable-go OSS, Railway phase 1 (10K)", + "date": "2026-06-23", + "infra": "Railway project gentle-commitment/production; bench-runner over internal network; anycable-go OSS as same-window canary (held expected shape on every test)", + "socketioxide": "0.18.4 (rust:1-slim release, [::]:3000)", + "anycable_go": "1.6.14 (broker=memory, presets=broker)", + "latency": { + "1k": { "socketioxide": { "deliv": 100, "p50": 23, "p99": 66 }, "anycable": { "deliv": 100, "p50": 16, "p99": 46 } }, + "10k": { "socketioxide": { "deliv": 100, "p50": 289, "p99": 972, "max": 3627 }, "anycable": { "deliv": 100, "p50": 232, "p99": 731, "max": 7798 } } + }, + "jitter_delivery_pct": { + "200_local": { "socketioxide": 91.6, "anycable": 100 }, + "1k_railway": { "socketioxide": 89.4, "anycable": 100 }, + "10k_railway": { "socketioxide_run1": 40.6, "socketioxide_run2": 32.7, "anycable": 100 } + }, + "finding": "socketioxide is at-most-once: in the band with default Socket.io / uWS up to 1K, then collapses under the 10K reconnect storm (41% then 33%, reproducible, no crash, 0 connect failures). AnyCable holds 100% (separate process + replay). Rust runtime does not rescue the in-process at-most-once architecture at scale." +} diff --git a/docs/socketioxide-comparison.md b/docs/socketioxide-comparison.md index 637b665..2d69892 100644 --- a/docs/socketioxide-comparison.md +++ b/docs/socketioxide-comparison.md @@ -153,26 +153,87 @@ language. Raw numbers: `backend/results/socketioxide-local-2026-06-23.json`. -### Not yet run (needs Railway) - -Latency 10K, jitter 10K, idle 1M, and the avalanche escalation need the -Railway services and the 50-shard bench-runner fleet. Deploy -`socketioxide-server` from `socketioxide/` (same hardware tier as the -other Socket.io targets), then run the socketioxide subset with AnyCable -alongside as the control: +### Railway run @ 10K, socketioxide vs AnyCable OSS (2026-06-23) + +Phase 1 on the real infra: `socketioxide-server` deployed to the same +Railway project as the page targets, `anycable-go` OSS woken alongside as +the same-window canary, both driven from the Railway-hosted bench-runner +over the internal network. AnyCable held its expected shape on every test +(latency in band, 100% delivery under jitter, 100% throughput), so the +window was healthy and the socketioxide numbers are not Railway noise. + +**Latency (jitter disabled):** + +| Scale | socketioxide p50 / p99 | AnyCable OSS p50 / p99 | Both delivery | +|---|---|---|---| +| 1K | 23 / 66 ms | 16 / 46 ms | 100% | +| 10K | 289 / 972 ms | 232 / 731 ms | 100% | + +Same order of magnitude. AnyCable is a touch faster at the tail; nothing +separates them in a way a user would feel. socketioxide delivers 100% when +the network is steady. + +**Delivery under jitter (TCP force-close every ~15 s, no replay protocol):** + +| Scale | socketioxide delivery | AnyCable OSS (canary) | +|---|---|---| +| 200 (local) | 91.6% | 100% | +| 1K (Railway) | 89.4% | 100% | +| 10K (Railway) | **40.6%**, then **32.7%** (two runs) | 100% | + +This is the headline finding. socketioxide sits in the at-most-once band +with default Socket.io (~85%) and uWS (~87%) up to 1K, then **collapses +under the 10K reconnect storm**: two independent runs landed at 41% and +33% delivery. The cliff is reproducible and is not a server crash (no +errors logged, 0 connect failures, 10K/10K clients connect every time) +and not Railway noise (AnyCable held 100% in the same windows). + +Two things compound at 10K. socketioxide is at-most-once, so anything that +lands during a client's offline window is gone. And the jitter path opens +a fresh connection per disruption (the standard Socket.io recovery, since +the protocol has no resume), so 10K clients churning ~7 reconnects each +is ~70K fresh handshakes against one in-process server. The Rust runtime +does not rescue the architecture: at scale, an in-process at-most-once WS +layer sheds most of its messages during a reconnect storm. AnyCable holds +100% because the WS layer is a separate process the disruption never +restarts, and replay recovers whatever the offline window missed. + +Caveat for fairness: AnyCable's jitter path reconnects the same cable +in place (its client library's built-in resume), which is lighter than +socketioxide's fresh-socket-per-event path. Part of the 10K gap is that +asymmetry, which is itself a property of having a resume protocol versus +not. The 1K row (89% socketioxide, same fresh-socket path) shows the +mechanism is sound at moderate scale; the 10K row shows where it breaks. + +Raw numbers: `backend/results/railway-phase1/` (per-test JSON) and +`backend/results/socketioxide-railway-2026-06-23.json` (summary). + +### Still pending: idle 1M + avalanche + +The connection-capacity (idle 1M) and avalanche-escalation rows need the +50-shard bench-runner fleet woken and, for avalanche, real redeploys of +`socketioxide-server` mid-test. Deferred to a phase 2. To run them later, +wake the fleet (see [`railway-ops.md`](./railway-ops.md)) and: ```bash cd backend BENCH_RUNNER_URL=https://bench-runner-production.up.railway.app \ BENCH_RUNNER_TOKEN= \ -FILTER=socketioxide,anycable \ +FILTER=socketioxide,anycable INCLUDE_IDLE=1 INCLUDE_AVALANCHE=1 \ npm run bench:rebaseline ``` -`FILTER=socketioxide,anycable` runs only the new Rust rows plus the -AnyCable rows as the same-window canary, skipping the rest of the matrix. -Multi-shard idle and avalanche entries gate behind `INCLUDE_IDLE=1` and -`INCLUDE_AVALANCHE=1` as usual. +### How phase 1 was deployed + +`socketioxide-server` is a net-new Railway service built from +`socketioxide/`. Three things the local build didn't catch surfaced on +Railway and are fixed in the Dockerfile / server: + +- Base image must be Rust 1.94+ (socketioxide 0.18.4 MSRV); `rust:1-slim`. +- Bind `[::]` not `0.0.0.0`: Railway's private network is IPv6, so an + IPv4-only bind is unreachable internally. +- Pin `PORT=3000` on the service so the listen port matches the manifest + target (Railway injects `PORT=8080` otherwise). ### Reproduce the local run From e66e2fc177525335259ee6da8479d047c3a4c19b Mon Sep 17 00:00:00 2001 From: Irina Nazarova Date: Mon, 22 Jun 2026 21:14:44 -0700 Subject: [PATCH 07/10] socketioxide: idle 600K capacity result (phase 2) Idle test was harness-limited at ~600K (both targets hit the identical ~600,090 ceiling because the bench-runner shards capped ~12K clients each, not because either server saturated; both sized 32GB peaked at ~21-22GB). At 600K held: socketioxide ~37 KB/conn (1.8% CPU), anycable-go ~39 KB/conn (9% CPU). Comparable per-connection memory, both well under Node Socket.io's ~52 KB. The notable finding: socketioxide held 600K+, ~5x past Node Socket.io's ~120K single-event-loop ceiling. tokio's multi-threading clears the wall that caps Node. So Rust fixes Socket.io's capacity limit (runtime concurrency) but not its at-most-once delivery limit (protocol). Set the idle-socketioxide targetServiceId in the manifest for metrics. Avalanche rows still running. --- .../socketioxide-railway-2026-06-23.json | 64 +++++++++++++++++-- backend/src/bench/tests-manifest.ts | 3 +- docs/socketioxide-comparison.md | 54 ++++++++++++---- 3 files changed, 100 insertions(+), 21 deletions(-) diff --git a/backend/results/socketioxide-railway-2026-06-23.json b/backend/results/socketioxide-railway-2026-06-23.json index 2369c7c..7a04702 100644 --- a/backend/results/socketioxide-railway-2026-06-23.json +++ b/backend/results/socketioxide-railway-2026-06-23.json @@ -5,13 +5,63 @@ "socketioxide": "0.18.4 (rust:1-slim release, [::]:3000)", "anycable_go": "1.6.14 (broker=memory, presets=broker)", "latency": { - "1k": { "socketioxide": { "deliv": 100, "p50": 23, "p99": 66 }, "anycable": { "deliv": 100, "p50": 16, "p99": 46 } }, - "10k": { "socketioxide": { "deliv": 100, "p50": 289, "p99": 972, "max": 3627 }, "anycable": { "deliv": 100, "p50": 232, "p99": 731, "max": 7798 } } + "1k": { + "socketioxide": { + "deliv": 100, + "p50": 23, + "p99": 66 + }, + "anycable": { + "deliv": 100, + "p50": 16, + "p99": 46 + } + }, + "10k": { + "socketioxide": { + "deliv": 100, + "p50": 289, + "p99": 972, + "max": 3627 + }, + "anycable": { + "deliv": 100, + "p50": 232, + "p99": 731, + "max": 7798 + } + } }, "jitter_delivery_pct": { - "200_local": { "socketioxide": 91.6, "anycable": 100 }, - "1k_railway": { "socketioxide": 89.4, "anycable": 100 }, - "10k_railway": { "socketioxide_run1": 40.6, "socketioxide_run2": 32.7, "anycable": 100 } + "200_local": { + "socketioxide": 91.6, + "anycable": 100 + }, + "1k_railway": { + "socketioxide": 89.4, + "anycable": 100 + }, + "10k_railway": { + "socketioxide_run1": 40.6, + "socketioxide_run2": 32.7, + "anycable": 100 + } }, - "finding": "socketioxide is at-most-once: in the band with default Socket.io / uWS up to 1K, then collapses under the 10K reconnect storm (41% then 33%, reproducible, no crash, 0 connect failures). AnyCable holds 100% (separate process + replay). Rust runtime does not rescue the in-process at-most-once architecture at scale." -} + "finding": "socketioxide is at-most-once: in the band with default Socket.io / uWS up to 1K, then collapses under the 10K reconnect storm (41% then 33%, reproducible, no crash, 0 connect failures). AnyCable holds 100% (separate process + replay). Rust runtime does not rescue the in-process at-most-once architecture at scale.", + "idle_600k": { + "note": "harness-limited at ~600K (shards capped ~12K each); neither target saturated", + "socketioxide": { + "connected": 600091, + "peakMemGB": 21.4, + "ramKbPerConn": 37, + "peakCpuPct": 1.8 + }, + "anycable_go": { + "connected": 600084, + "peakMemGB": 22.1, + "ramKbPerConn": 39, + "peakCpuPct": 9.0 + }, + "finding": "socketioxide held 600K+ (5x past Node Socket.io ~120K event-loop ceiling); RAM/conn comparable to anycable. Rust runtime fixes the capacity wall, not the at-most-once delivery limit." + } +} \ No newline at end of file diff --git a/backend/src/bench/tests-manifest.ts b/backend/src/bench/tests-manifest.ts index 9397d0a..ad89784 100644 --- a/backend/src/bench/tests-manifest.ts +++ b/backend/src/bench/tests-manifest.ts @@ -483,8 +483,6 @@ export const tests: TestSpec[] = [ driftThresholdPct: 60, }, // socketioxide idle: same multi-shard fan-out, targets the Rust service. - // No targetServiceId yet — fill in once the Railway service is created - // so the runner can pull memory/CPU from Railway metrics. { id: "idle-socketioxide", description: "Idle connections held, socketioxide (Rust), 1M target", @@ -494,6 +492,7 @@ export const tests: TestSpec[] = [ numShards: 50, perShardN: 20000, params: { hold: 120, ramp: 200, stream: "idle-rebaseline", serverUrl: TARGETS.socketioxide }, + targetServiceId: "41f1ac22-2ea6-4d04-974e-4c148be426ff", baseline: {}, driftThresholdPct: 60, }, diff --git a/docs/socketioxide-comparison.md b/docs/socketioxide-comparison.md index 2d69892..d828dc0 100644 --- a/docs/socketioxide-comparison.md +++ b/docs/socketioxide-comparison.md @@ -208,20 +208,50 @@ mechanism is sound at moderate scale; the 10K row shows where it breaks. Raw numbers: `backend/results/railway-phase1/` (per-test JSON) and `backend/results/socketioxide-railway-2026-06-23.json` (summary). -### Still pending: idle 1M + avalanche +### Idle connection capacity @ ~600K (phase 2, 2026-06-23) -The connection-capacity (idle 1M) and avalanche-escalation rows need the -50-shard bench-runner fleet woken and, for avalanche, real redeploys of -`socketioxide-server` mid-test. Deferred to a phase 2. To run them later, -wake the fleet (see [`railway-ops.md`](./railway-ops.md)) and: +50-shard fleet woken, both targets sized 32 GB / 32 vCPU, each ramped +toward 1M idle socket.io/cable connections. -```bash -cd backend -BENCH_RUNNER_URL=https://bench-runner-production.up.railway.app \ -BENCH_RUNNER_TOKEN= \ -FILTER=socketioxide,anycable INCLUDE_IDLE=1 INCLUDE_AVALANCHE=1 \ - npm run bench:rebaseline -``` +**The harness capped before either server did.** Both socketioxide and +anycable-go held ~600K and then the bench-runner shards ran out of +client-side capacity (~12K socket.io/cable clients per shard, well under +the ~50K port limit, so the shards' own event loops / memory were the +wall). The near-identical ceiling (600,091 vs 600,084) is the tell: it's +a property of the load generator, not the servers. Neither target +saturated; both sized 32 GB peaked at ~21-22 GB with ~10 GB headroom. + +| At ~600K held | Peak memory | RAM / conn | Peak CPU | +|---|---|---|---| +| socketioxide | 21.4 GB | ~37 KB | 1.8% | +| anycable-go OSS | 22.1 GB | ~39 KB | 9.0% | + +Two things worth stating. First, per-connection memory is comparable: +~37 KB (socketioxide) vs ~39 KB (anycable-go), both far below Node +Socket.io's ~52 KB. Second, and more telling: **socketioxide held 600K+ +idle connections, about 5x past Node Socket.io's ~120K ceiling.** Node +Socket.io caps there because handshakes serialise through one event loop +regardless of memory; socketioxide's multi-threaded tokio runtime clears +that wall. So the Rust implementation fixes Socket.io's idle-capacity +problem (a runtime/concurrency limit) even though it cannot fix the +at-most-once delivery problem (a protocol limit). The two ceilings have +different causes, and only one of them is about the language. + +Caveat: 600K is harness-limited, so this is a floor on each server's true +capacity, not the ceiling. The RAM/conn figures include the memory churn +of ~400K failed connection attempts hitting each target during ramp, so +treat them as approximate upper bounds. To find the real server ceilings +we would need larger bench-runner shards or more of them. + +Raw: `backend/results/railway-phase2/idle-*.json`. + +### Avalanche (deploy survival) @ 5K / 10K / 20K + +*Running. Results land here once the escalation completes. Each scale +ramps N socket.io clients against `socketioxide-server`, then a real +`railway redeploy` swaps the container mid-test; we measure recovery +time and reconnect rate, same methodology as the Socket.io avalanche +ladder.* ### How phase 1 was deployed From 4c97d81108b7e679cf7b0a86067ec05b6310fb2a Mon Sep 17 00:00:00 2001 From: Irina Nazarova Date: Mon, 22 Jun 2026 23:20:31 -0700 Subject: [PATCH 08/10] socketioxide: avalanche results (5K/10K/20K) + report Avalanche escalation on Railway: 5K recovers 100% in 2.9s, 10K is 96% in 67s (411 never back), 20K collapses to 0% recovered. Tracks Node Socket.io's cliff almost exactly (Socket.io 10K ~65s/96%). The in-process WS layer dies with the app deploy regardless of runtime language; the reconnect storm overwhelms the restarted single instance at scale. AnyCable is 0s by construction (separate process). Raw under backend/results/railway-phase2/. --- .../socketioxide-railway-2026-06-23.json | 19 +++++++++++ docs/socketioxide-comparison.md | 33 ++++++++++++++++--- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/backend/results/socketioxide-railway-2026-06-23.json b/backend/results/socketioxide-railway-2026-06-23.json index 7a04702..0e0edc1 100644 --- a/backend/results/socketioxide-railway-2026-06-23.json +++ b/backend/results/socketioxide-railway-2026-06-23.json @@ -63,5 +63,24 @@ "peakCpuPct": 9.0 }, "finding": "socketioxide held 600K+ (5x past Node Socket.io ~120K event-loop ceiling); RAM/conn comparable to anycable. Rust runtime fixes the capacity wall, not the at-most-once delivery limit." + }, + "avalanche": { + "5k": { + "reconnectPct": 100, + "recoveryMs": 2922, + "neverBack": 0 + }, + "10k": { + "reconnectPct": 96, + "recoveryMs": 67271, + "neverBack": 411 + }, + "20k": { + "reconnectPct": 0, + "recoveryMs": 610836, + "neverBack": "all", + "note": "client side capped ~12K; 0% recovery unambiguous" + }, + "finding": "socketioxide tracks Node Socket.io avalanche cliff: 5K recovers, 10K ~67s/96%, 20K collapses to 0%. In-process WS dies on deploy regardless of language." } } \ No newline at end of file diff --git a/docs/socketioxide-comparison.md b/docs/socketioxide-comparison.md index d828dc0..46fd6ad 100644 --- a/docs/socketioxide-comparison.md +++ b/docs/socketioxide-comparison.md @@ -247,11 +247,34 @@ Raw: `backend/results/railway-phase2/idle-*.json`. ### Avalanche (deploy survival) @ 5K / 10K / 20K -*Running. Results land here once the escalation completes. Each scale -ramps N socket.io clients against `socketioxide-server`, then a real -`railway redeploy` swaps the container mid-test; we measure recovery -time and reconnect rate, same methodology as the Socket.io avalanche -ladder.* +Each scale ramps N socket.io clients against `socketioxide-server`, then +a real `railway redeploy` swaps the container mid-test (same methodology +as the Socket.io avalanche ladder). socketioxide tracks Node Socket.io's +cliff almost exactly: + +| Clients | Reconnected | Recovery | Never back | +|---|---|---|---| +| 5,000 | 100% | 2.9 s | 0 | +| 10,000 | 96% | 67 s | 411 | +| 20,000 | **0%** | never (capped at 10 min) | all | + +On the deploy, every connection drops (in-process WS dies with the app, +the architectural fact). At 5K it recovers cleanly. By 10K the reconnect +storm against the freshly-restarted single instance stretches recovery to +over a minute with a few hundred clients never returning. By 20K it +collapses to 0% recovered, the same cliff Node Socket.io hits around 25K +on the page (Socket.io 10K was ~65 s / 96%, near-identical to +socketioxide's 67 s / 96%). The Rust runtime does not change the shape: +an in-process WS layer cannot survive its own app's deploy, and the +reconnect storm overwhelms the new instance at scale regardless of +language. AnyCable's avalanche row is 0 s by construction: the WS process +is never restarted by an app deploy. + +(The 20K row is muddied by the client side: one bench-runner caps near +~12K socket.io clients, so the 20K avalanche only fully ramped ~12K. The +0% recovery is unambiguous either way.) + +Raw: `backend/results/railway-phase2/avalanche-socketioxide-*.json`. ### How phase 1 was deployed From e4bf3b09f83fb183ea613258f30eed659047986b Mon Sep 17 00:00:00 2001 From: Irina Nazarova Date: Tue, 23 Jun 2026 11:06:01 -0700 Subject: [PATCH 09/10] socketioxide: 1M idle push attempt + phase-2 teardown Traced the 600K idle cap to a hard ~12,002 connections/shard (ephemeral-port exhaustion to one host:port from one source IP; not memory, containers report nofile=122880). Grew the fleet 50 -> 85 shards (~1.02M theoretical) and re-ran: 49 shards delivered a clean 12,000 each (588,000, 0 failures), 36 errored/timed out under the coordinator fan-out. Harness-limited, not server-limited; socketioxide accepted every connection the surviving shards threw with memory headroom. Established: socketioxide holds at least ~600K idle socket.io connections on a 32GB box, ~5x past Node Socket.io's ~120K event-loop ceiling, RAM/conn comparable to AnyCable. True ceiling unmeasured (needs more source IPs / a lighter idle client than socket.io-client). Phase-2 teardown: all 87 services stopped (85 shards + anycable-go + socketioxide-server), both 32GB targets downsized to 0.5GB/1vCPU, verified offline. Comparison page untouched throughout. --- .../socketioxide-railway-2026-06-23.json | 11 +++++++++- docs/socketioxide-comparison.md | 22 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/backend/results/socketioxide-railway-2026-06-23.json b/backend/results/socketioxide-railway-2026-06-23.json index 0e0edc1..0d041ab 100644 --- a/backend/results/socketioxide-railway-2026-06-23.json +++ b/backend/results/socketioxide-railway-2026-06-23.json @@ -82,5 +82,14 @@ "note": "client side capped ~12K; 0% recovery unambiguous" }, "finding": "socketioxide tracks Node Socket.io avalanche cliff: 5K recovers, 10K ~67s/96%, 20K collapses to 0%. In-process WS dies on deploy regardless of language." - } + }, + "idle_1M_attempt": { + "per_shard_cap": 12002, + "cap_cause": "ephemeral-port exhaustion to single host:port from one source IP (not memory; nofile=122880)", + "fleet_grown_to": 85, + "theoretical_capacity": 1020000, + "result": "49/85 shards clean (588000, 0 failures); 36 shards errored/timed out under coordinator fan-out", + "conclusion": "harness-limited, not server-limited; socketioxide held every connection thrown (588K, 0 failures) with memory headroom. True ceiling unmeasured; needs more source IPs or a lighter idle client." + }, + "teardown": "all 87 services (85 shards + anycable-go + socketioxide-server) stopped; both targets downsized 32GB->0.5GB/1vCPU; verified offline" } \ No newline at end of file diff --git a/docs/socketioxide-comparison.md b/docs/socketioxide-comparison.md index 46fd6ad..c291073 100644 --- a/docs/socketioxide-comparison.md +++ b/docs/socketioxide-comparison.md @@ -243,6 +243,28 @@ of ~400K failed connection attempts hitting each target during ramp, so treat them as approximate upper bounds. To find the real server ceilings we would need larger bench-runner shards or more of them. +**Pushing toward 1M (and why we stopped at the harness).** We traced the +600K cap to a hard per-shard limit of ~12,002 connections to a single +`host:port` from one source IP (ephemeral-port exhaustion, not memory: +the bench-runner containers report `nofile=122880`). 50 shards x 12K = +600K, exactly the cap. To go higher we grew the fleet to 85 shards +(~1.02M of theoretical capacity) and re-ran. The expanded fleet got +flaky: 49 of 85 shards delivered a clean 12,000 each (588,000 total, 0 +failures on those) while 36 shards errored or timed out under the +coordinator's fan-out. So the load generator, not socketioxide, remained +the wall, and we did not land a clean 1M. socketioxide itself never +showed stress: it accepted every connection the surviving shards threw +(588K, 0 failures) and stayed well under its memory limit. + +What this establishes: socketioxide comfortably holds **at least ~600K** +idle socket.io connections on a 32 GB box with headroom to spare, roughly +5x past Node Socket.io's ~120K event-loop ceiling, at per-connection +memory comparable to AnyCable. We could not measure its true ceiling +because reaching 1M needs a more capable load-generation fleet (more +source IPs, or a lighter idle client than socket.io-client). That is a +harness limitation, and the page does not claim a socketioxide idle +ceiling on the strength of it. + Raw: `backend/results/railway-phase2/idle-*.json`. ### Avalanche (deploy survival) @ 5K / 10K / 20K From 2c03e934858083980f9603af1bf93bf9c987981d Mon Sep 17 00:00:00 2001 From: Irina Nazarova Date: Tue, 23 Jun 2026 15:06:48 -0700 Subject: [PATCH 10/10] README: add socketioxide results section to the report Promote the socketioxide head-to-head from a one-line pointer to a full results section in the README: latency, jitter, avalanche, and idle tables vs AnyCable, with the takeaway that Rust fixes Socket.io's capacity ceiling but not its at-most-once delivery or in-process deploy fragility. Deep dive stays in docs/socketioxide-comparison.md. --- README.md | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ce27775..3c938ba 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ The repo behind [anycable.io/compare/nodejs-websocket](https://anycable.io/compa Setups under test: default Socket.io, Socket.io + Connection State Recovery, uWebSockets.js, AnyCable OSS, AnyCable Pro. -Additional target on request: [socketioxide](https://github.com/totodore/socketioxide) (Rust Socket.io server), in `socketioxide/`. Head-to-head vs AnyCable in [`docs/socketioxide-comparison.md`](./docs/socketioxide-comparison.md): comparable steady-state latency, and at-most-once delivery that sits in the Socket.io band up to 1K (89% under jitter) then collapses under the 10K reconnect storm (33-41% vs AnyCable's 100%). The Rust runtime does not rescue the in-process architecture at scale. Idle 1M + avalanche rows pending a phase 2. +Sixth target, [socketioxide](https://github.com/totodore/socketioxide) (Rust Socket.io server), benchmarked head-to-head with AnyCable on the same Railway hardware. Results in [its own section below](#socketioxide-rust-socketio); deep dive in [`docs/socketioxide-comparison.md`](./docs/socketioxide-comparison.md). Methodology, traps, and the bugs we caught in our own setup: [`docs/methodology.md`](./docs/methodology.md). Below: the numbers and how to rerun them. @@ -79,6 +79,37 @@ Three knobs that shape the numbers. Full reasoning in [`docs/methodology.md`](./ - **CSR runs with the in-memory adapter.** Simplest opt-in path. Redis Streams or MongoDB shift the tail by adding network RTT; structural picture holds. CSR is documented as incompatible with the Redis pub/sub adapter, so the "Redis adapter" most teams reach for first is the one CSR can't use. - **AnyCable's jitter-row RAM is the tradeoff for parallel replay.** Its history buffer is per-stream so `history` parallelises across streams; that costs more RAM during jittery runs. Page-level RAM-per-connection comes from the idle test, where the per-connection footprint is what's measured. +## socketioxide (Rust Socket.io) + +[socketioxide](https://github.com/totodore/socketioxide) speaks the Socket.io wire protocol in Rust. Its author asked us to benchmark it, so we ran it head-to-head with AnyCable on the same Railway hardware, AnyCable alongside as a same-window control. It answers a sharp question: which of Socket.io's problems are about the runtime language, and which are about the architecture? + +**Latency (steady network).** Comparable to AnyCable. Nothing a user feels. + +| | 1K p50 / p99 | 10K p50 / p99 | Delivery | +| --- | --- | --- | --- | +| socketioxide | 23 / 66 ms | 289 / 972 ms | 100% | +| AnyCable OSS | 16 / 46 ms | 232 / 731 ms | 100% | + +**Delivery under jitter.** At-most-once, no replay. In the Socket.io band to 1K, then it falls off a cliff. + +| Clients | socketioxide | AnyCable | +| --- | --- | --- | +| 200 (local) | 91.6% | 100% | +| 1,000 | 89.4% | 100% | +| 10,000 | **41% then 33%** (two runs) | 100% | + +**Avalanche (app deploy).** Every connection dies on the deploy (in-process WS goes down with the app), and recovery collapses at scale, tracking Node Socket.io almost exactly. + +| Clients | Reconnected | Recovery | +| --- | --- | --- | +| 5,000 | 100% | 2.9 s | +| 10,000 | 96% | 67 s | +| 20,000 | **0%** | never | + +**Idle capacity.** Held **600K+** idle connections at ~37 KB each (comparable to AnyCable's ~39 KB), roughly 5x past Node Socket.io's ~120K event-loop ceiling. We could not find its true ceiling: the load-generation fleet caps near ~12K connections per shard (ephemeral ports), so the harness ran out before socketioxide did. + +**The takeaway.** Rust fixes Socket.io's capacity ceiling, the single-event-loop wall that caps Node around 120K. It leaves two things untouched: at-most-once delivery (no replay protocol) and deploy fragility (the WS layer still dies with its app). Both live in the protocol and the topology, so swapping the language to Rust leaves them intact, and socketioxide collapses under jitter and deploy storms at scale the same way Node Socket.io does. AnyCable holds 100% on both because the WS layer is a separate process with replay. Full numbers and the deploy story: [`docs/socketioxide-comparison.md`](./docs/socketioxide-comparison.md). + ## Repository layout ```