mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
The WebSocket message-type schema is the one artifact in this repository that neither component owns: `Server/ws/message_types.go` and `Client/src/lib/protocolTypes.ts` are both generated from it, and neither may be hand-edited. It nonetheless lived at `docs/protocol-schema.json` — filed under the directory for prose, whose own README calls it "Reference" material — and its generator lived at `Server/scripts/genprotocol/`, i.e. inside one of the two consumers. Ownership was legible from neither location. The obvious fix — move the generator to the repository root alongside the schema, so the whole tool is at the cross-component boundary — is wrong here. The generator is a Go `package main`, and Go modules are directory-rooted: `Server/go.mod` roots at `Server/`, so a root-level Go program needs a second module or a `go.work`. That second module would sit outside every path filter this repository already has — `golangci-lint` runs with `working-directory: Server/` (ci.yml), `go vet ./...` runs from `Server/` (scripts/run.mjs, .githooks/pre-commit), `.githooks/pre-commit` selects Go files with `^Server/.*\.go$`, `.githooks/pre-push` sets `server_changed` on `^Server/`, setup-go caches on `Server/go.sum`, and dependabot has one gomod block for `/Server`. Six gates would silently stop covering the generator, each failing open. The schema is data and moves freely; the generator is Go and stays where the Go toolchain already runs. Done instead: - `docs/protocol-schema.json` -> `protocol/schema.json`. A new top-level `protocol/` is the cross-component boundary, with a `README.md` naming the two generated consumers, the one command, and the four gates. - `Server/scripts/genprotocol/` -> `Server/cmd/genprotocol/`, the module's conventional home for an executable. This also empties `Server/scripts/` of Go entry points except `seed.go`, which RL-10 moves next. - `Server/cmd/` added to `Server/.dockerignore` and `Server/.air.toml`, which both already excluded `Server/scripts/`. Without this the move would have silently widened the Docker build context and the air watch set. 27 files, 115 insertions, 76 deletions. Two runtime path resolvers re-pointed (`cmd/genprotocol/main.go:41` `-schema` default, `ws/protocol_contract_test.go:67` `filepath.Join`); two git-hook grep patterns (`pre-commit:53`, `pre-push:57`); eight generator call sites across five files (Makefile x2, scripts/run.mjs x2, pre-commit x2, ci-check skill, bughunt-fix.js); two broken relative markdown links (docs/README.md:47, docs/protocol.md:1497); two generated files regenerated, header lines only, zero constants changed; two ledger prose hits plus a `render-ledger.mjs` re-render. No new verify was written: the regenerate-and-diff check is already enforced three times (CI `make protocol-verify`, `.githooks/pre-commit`, `npm run check:server`) and `ws/protocol_contract_test.go` independently checks the schema against the constants a fourth time. Verified: both directions, for both resolvers. With `protocol/schema.json` removed, `go test ./ws/ -run TestProtocol` fails with `reading protocol schema at /home/user/OwnCord/protocol/schema.json: no such file or directory` (two tests) and `go run ./cmd/genprotocol` exits 1 with `read schema: open ../protocol/schema.json: no such file or directory`; with the file restored both pass. So the new path is genuinely resolved, not merely spelled in a comment. The hook patterns were exercised directly: the pre-commit pattern matches `protocol/schema.json` and `Server/cmd/genprotocol/main.go` and no longer matches `docs/protocol-schema.json`; the pre-push pattern matches `protocol/schema.json`. `go run ./cmd/genprotocol` twice in a row leaves `git diff --exit-code ws/message_types.go ../Client/src/lib/protocolTypes.ts` clean, so the committed outputs are exactly what the generator emits. `go build ./...` and `go vet ./...` pass; `npx prettier --check .`, `npm run typecheck` and `npm run lint` pass; `node .superpowers/render-ledger.mjs --check` reports 348 findings valid. Not included: the four dated `docs/audit-*.md` files, the older `docs/plans/*`, and `CHANGELOG.md` keep the old path — they are point-in-time records, and `.prettierignore` and `scripts/check-doc-counts.mjs` already treat them as deliberately unmaintained. The B1 plan itself keeps its own wording, since it states intent rather than current state. `Server/scripts/` is not deleted: it still holds `seed.go` (RL-10), `k6/`, `toxiproxy/` and two shell scripts. `Server/telemetry/metrics.go:19` declares a scope for a `Server/voice` package that does not exist — spotted here, unrelated to this move, left for RL-13's sweep to carry forward verbatim rather than fixed inside a relocation. No `seed:` Make target was added. Refs RL-09, L-09
241 lines
7.5 KiB
JavaScript
241 lines
7.5 KiB
JavaScript
// k6 WebSocket load test for OwnCord server
|
|
// Run: k6 run --vus 50 --duration 60s scripts/k6/ws-load.js
|
|
//
|
|
// The wire protocol is the envelope format from docs/protocol.md: every
|
|
// client->server frame is {type, id?, payload:{...}} and the first frame MUST
|
|
// be an `auth` envelope. If you change protocol/schema.json, grep this
|
|
// script — it is not generated and CI does not run it, so it rots silently
|
|
// (it once drifted to pre-envelope framing and reported green while every
|
|
// auth failed).
|
|
//
|
|
// Prerequisites: the target server must already have the loadtest users
|
|
// (K6_USERNAME<vu-number>, all sharing K6_PASSWORD) registered, and the
|
|
// target channel readable by them.
|
|
//
|
|
// Environment variables:
|
|
// K6_WS_URL - WebSocket URL (default: wss://localhost:8443/api/v1/ws)
|
|
// K6_HTTP_URL - HTTP base URL (default: https://localhost:8443)
|
|
// K6_USERNAME - Test user prefix (default: loadtest)
|
|
// K6_PASSWORD - Test user password (default: LoadTest123!)
|
|
// K6_CHANNEL_ID - Channel ID to send messages in (default: 1)
|
|
//
|
|
// Self-signed TLS (the default server cert): run k6 with --insecure-skip-tls-verify.
|
|
|
|
import ws from "k6/ws";
|
|
import http from "k6/http";
|
|
import { check, sleep } from "k6";
|
|
import { Counter, Rate, Trend } from "k6/metrics";
|
|
|
|
// Custom metrics
|
|
const wsConnections = new Counter("ws_connections");
|
|
const wsAuthed = new Counter("ws_authed");
|
|
const wsReady = new Counter("ws_ready");
|
|
const wsMessages = new Counter("ws_messages_sent");
|
|
const wsAcks = new Counter("ws_send_acks");
|
|
const wsErrors = new Counter("ws_errors");
|
|
const wsConnectTime = new Trend("ws_connect_time", true);
|
|
const wsMessageRate = new Rate("ws_message_success");
|
|
const authTime = new Trend("auth_time", true);
|
|
const broadcastLatency = new Trend("ws_broadcast_latency_ms", true);
|
|
|
|
// Configuration
|
|
const WS_URL = __ENV.K6_WS_URL || "wss://localhost:8443/api/v1/ws";
|
|
const HTTP_URL = __ENV.K6_HTTP_URL || "https://localhost:8443";
|
|
const USERNAME_PREFIX = __ENV.K6_USERNAME || "loadtest";
|
|
const PASSWORD = __ENV.K6_PASSWORD || "LoadTest123!";
|
|
const CHANNEL_ID = parseInt(__ENV.K6_CHANNEL_ID || "1");
|
|
|
|
export const options = {
|
|
scenarios: {
|
|
// Ramp up connections gradually
|
|
websocket_load: {
|
|
executor: "ramping-vus",
|
|
startVUs: 0,
|
|
stages: [
|
|
{ duration: "10s", target: 10 }, // warm up
|
|
{ duration: "30s", target: 50 }, // ramp to 50
|
|
{ duration: "60s", target: 50 }, // sustain
|
|
{ duration: "10s", target: 100 }, // spike
|
|
{ duration: "30s", target: 100 }, // sustain spike
|
|
{ duration: "10s", target: 0 }, // ramp down
|
|
],
|
|
},
|
|
},
|
|
thresholds: {
|
|
ws_connect_time: ["p(95)<2000"], // 95% connect under 2s
|
|
ws_message_success: ["rate>0.95"], // 95% of sends acked
|
|
ws_errors: ["count<50"], // fewer than 50 errors
|
|
auth_time: ["p(95)<1000"], // 95% auth under 1s
|
|
// A run where nobody authenticated or went ready is a broken run, no
|
|
// matter how green everything else looks — this is the assertion that
|
|
// was missing when the script drifted off the wire protocol.
|
|
ws_authed: ["count>0"],
|
|
ws_ready: ["count>0"],
|
|
},
|
|
};
|
|
|
|
// envelope wraps a client->server frame in the protocol's outer shape.
|
|
function envelope(type, payload) {
|
|
return JSON.stringify({ type: type, payload: payload });
|
|
}
|
|
|
|
// Login and get session token
|
|
function authenticate(username) {
|
|
const start = Date.now();
|
|
const res = http.post(
|
|
`${HTTP_URL}/api/v1/auth/login`,
|
|
JSON.stringify({ username, password: PASSWORD }),
|
|
{ headers: { "Content-Type": "application/json" } },
|
|
);
|
|
authTime.add(Date.now() - start);
|
|
|
|
if (res.status !== 200) {
|
|
wsErrors.add(1);
|
|
return null;
|
|
}
|
|
|
|
const body = JSON.parse(res.body);
|
|
return body.token;
|
|
}
|
|
|
|
export default function () {
|
|
const vuId = __VU;
|
|
const username = `${USERNAME_PREFIX}${vuId}`;
|
|
|
|
// Authenticate
|
|
const token = authenticate(username);
|
|
if (!token) {
|
|
sleep(1);
|
|
return;
|
|
}
|
|
|
|
// Connect WebSocket
|
|
const connectStart = Date.now();
|
|
const res = ws.connect(WS_URL, null, function (socket) {
|
|
wsConnectTime.add(Date.now() - connectStart);
|
|
wsConnections.add(1);
|
|
|
|
let authed = false;
|
|
let ready = false;
|
|
let msgCount = 0;
|
|
const maxMessages = 10;
|
|
const pendingSends = {}; // send-id -> Date.now() at send
|
|
|
|
// First frame must be the auth envelope (serve_auth.go).
|
|
socket.send(envelope("auth", { token: token }));
|
|
|
|
socket.on("message", function (msg) {
|
|
try {
|
|
const data = JSON.parse(msg);
|
|
switch (data.type) {
|
|
case "auth_ok":
|
|
authed = true;
|
|
wsAuthed.add(1);
|
|
break;
|
|
case "auth_error":
|
|
wsErrors.add(1);
|
|
socket.close();
|
|
break;
|
|
case "ready":
|
|
ready = true;
|
|
wsReady.add(1);
|
|
socket.send(envelope("channel_focus", { channel_id: CHANNEL_ID }));
|
|
break;
|
|
case "chat_send_ok":
|
|
wsAcks.add(1);
|
|
wsMessageRate.add(true);
|
|
if (data.id && pendingSends[data.id]) {
|
|
broadcastLatency.add(Date.now() - pendingSends[data.id]);
|
|
delete pendingSends[data.id];
|
|
}
|
|
break;
|
|
case "error":
|
|
wsErrors.add(1);
|
|
wsMessageRate.add(false);
|
|
break;
|
|
default:
|
|
// Broadcast traffic (chat_message, presence, typing, seq'd
|
|
// frames) — receiving it is the point of the load, no assertion.
|
|
break;
|
|
}
|
|
} catch (_e) {
|
|
wsErrors.add(1);
|
|
}
|
|
});
|
|
|
|
socket.on("error", function (_e) {
|
|
wsErrors.add(1);
|
|
});
|
|
|
|
// Send messages periodically (respecting rate limits). Gated on ready:
|
|
// sends before the session is established only measure error handling.
|
|
socket.setInterval(function () {
|
|
if (!ready) {
|
|
return;
|
|
}
|
|
if (msgCount >= maxMessages) {
|
|
socket.close();
|
|
return;
|
|
}
|
|
const id = `${vuId}-${msgCount}-${Date.now()}`;
|
|
pendingSends[id] = Date.now();
|
|
socket.send(
|
|
JSON.stringify({
|
|
type: "chat_send",
|
|
id: id,
|
|
payload: {
|
|
channel_id: CHANNEL_ID,
|
|
content: `Load test message ${vuId}-${msgCount}`,
|
|
},
|
|
}),
|
|
);
|
|
wsMessages.add(1);
|
|
msgCount++;
|
|
}, 2000); // 1 message every 2 seconds (well under rate limit)
|
|
|
|
// Typing indicators (client->server type is typing_start, not "typing").
|
|
socket.setInterval(function () {
|
|
if (ready && msgCount < maxMessages) {
|
|
socket.send(envelope("typing_start", { channel_id: CHANNEL_ID }));
|
|
}
|
|
}, 4000);
|
|
|
|
// Presence updates (client->server type is presence_update; bare
|
|
// "presence" is the server->client broadcast).
|
|
socket.setInterval(function () {
|
|
if (authed) {
|
|
socket.send(envelope("presence_update", { status: "online" }));
|
|
}
|
|
}, 15000);
|
|
|
|
// Keep connection alive for the test duration
|
|
socket.setTimeout(function () {
|
|
socket.close();
|
|
}, 25000);
|
|
});
|
|
|
|
check(res, {
|
|
"WebSocket status is 101": (r) => r && r.status === 101,
|
|
});
|
|
|
|
if (!res || res.status !== 101) {
|
|
wsErrors.add(1);
|
|
wsMessageRate.add(false);
|
|
}
|
|
|
|
sleep(1);
|
|
}
|
|
|
|
export function handleSummary(data) {
|
|
return {
|
|
stdout: textSummary(data, { indent: " ", enableColors: true }),
|
|
"reports/k6-summary.json": JSON.stringify(data, null, 2),
|
|
};
|
|
}
|
|
|
|
// Built-in k6 text summary
|
|
function textSummary(data, opts) {
|
|
// k6 handles this automatically when not overridden
|
|
return JSON.stringify(data, null, 2);
|
|
}
|