Local API stress (lib.mjs / api_stress.mjs): - setupSession now does login -> /admin/api/reset and returns sid="main". Drops the dead /admin/api/quizzes + /admin/api/sessions calls left over from the multi-quiz codex era. - bootServer writes the fixture pool (STRESS_POOL by default) to a tmp file and passes QUIZ_POOL_PATH so the v1.2 server has a session at startup. - happyPath: drop the post-connect lobby_update wait (race with snapshot dispatch) and stop double-driving the lifecycle (next() already opens the next question, an explicit open() afterwards is a no-op). - cross_session: rewritten as "cookie not honored on a non-existent sid" since v1.2 hosts a single canonical session. Live accuracy stress (live_accuracy.mjs): - Per-student lobby-snapshot timeout (12s) with WS error/close rejection, so a stalled handshake no longer hangs Promise.all until the outer shell timeout (which produced the exit=124 cycles). - Open all student WSs in parallel (mirrors what real students do); the batch-of-8 throttle was masking the question we wanted answered. - Instructor WS open also bounded by a 15s race so any failure surfaces as actionable error text instead of a silent stall. Bootstrap (deploy/bootstrap.sh): - Stage 1 provisions a 2GB swap file (idempotent) with vm.swappiness=10. 1GB-RAM ECS instances OOM-kill uvicorn under WS-burst start-of-class pressure; swap absorbs the spike without affecting steady state. - Pool seeding prefers examples/demo10_pool.json over the 2-question example so a fresh deploy boots with a usable demo. Pool fixture (examples/demo10_pool.json): - 10-question generic-knowledge demo pool, gitignore exception added.
266 lines
10 KiB
JavaScript
266 lines
10 KiB
JavaScript
// Shared helpers for quiz portal stress tests.
|
|
// Boots a fresh uvicorn server, logs in as admin, creates quiz + session.
|
|
// Provides a Student class that wraps an authenticated WS + cookie state.
|
|
|
|
import { spawn } from "node:child_process";
|
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
import WebSocket from "ws";
|
|
|
|
const QUIZ_ROOT = "/home/ameer/RD/Projects/Apps/quiz";
|
|
const PORT_BASE = 8200;
|
|
|
|
export function nowMs() { return Date.now(); }
|
|
export function logLine(scenario, level, msg, extra = {}) {
|
|
const rec = { ts: new Date().toISOString(), scenario, level, msg, ...extra };
|
|
process.stdout.write(JSON.stringify(rec) + "\n");
|
|
}
|
|
|
|
export function pickRandom(arr) { return arr[Math.floor(Math.random() * arr.length)]; }
|
|
export function rand(min, max) { return Math.random() * (max - min) + min; }
|
|
|
|
// Boot a fresh server on its own port + DB + pool file. Returns { url, stop }.
|
|
// v1.2 single-session: server reads ONE pool from $QUIZ_POOL_PATH at startup.
|
|
// We write STRESS_POOL (or the supplied `pool`) to a file in a fresh tmp dir
|
|
// per server, so concurrent harness processes don't share state.
|
|
export async function bootServer({ port, secret = "stress-secret-12345678", adminPw = "stresspw", pool = STRESS_POOL } = {}) {
|
|
const tmp = mkdtempSync(join(tmpdir(), "quiz-stress-"));
|
|
const dbPath = join(tmp, "stress.db");
|
|
const poolPath = join(tmp, "pool.json");
|
|
writeFileSync(poolPath, JSON.stringify(pool), "utf-8");
|
|
const env = {
|
|
...process.env,
|
|
QUIZ_DB_PATH: dbPath,
|
|
QUIZ_SECRET_KEY: secret,
|
|
QUIZ_ADMIN_PASSWORD: adminPw,
|
|
QUIZ_HOST: "127.0.0.1",
|
|
QUIZ_PORT: String(port),
|
|
QUIZ_PUBLIC_URL: `http://127.0.0.1:${port}`,
|
|
QUIZ_POOL_PATH: poolPath,
|
|
QUIZ_SESSION_ID: "main",
|
|
};
|
|
const proc = spawn(
|
|
`${QUIZ_ROOT}/.venv/bin/uvicorn`,
|
|
["app.main:app", "--host", "127.0.0.1", "--port", String(port), "--log-level", "warning"],
|
|
{ cwd: QUIZ_ROOT, env, stdio: ["ignore", "pipe", "pipe"] },
|
|
);
|
|
// Pipe server stderr to our stderr so panics are visible
|
|
proc.stderr.on("data", chunk => process.stderr.write(`[server:${port}] ${chunk}`));
|
|
|
|
const url = `http://127.0.0.1:${port}`;
|
|
// Wait for /healthz
|
|
const deadline = Date.now() + 20_000;
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const r = await fetch(`${url}/healthz`);
|
|
if (r.ok) {
|
|
return {
|
|
url,
|
|
adminPw,
|
|
stop: () => new Promise(res => {
|
|
proc.once("exit", () => { rmSync(tmp, { recursive: true, force: true }); res(); });
|
|
proc.kill("SIGTERM");
|
|
// Hard-kill fallback
|
|
setTimeout(() => proc.kill("SIGKILL"), 2000);
|
|
}),
|
|
};
|
|
}
|
|
} catch {}
|
|
await sleep(150);
|
|
}
|
|
proc.kill("SIGKILL");
|
|
throw new Error(`server on ${port} did not come up`);
|
|
}
|
|
|
|
// Cookie jar helper - parses Set-Cookie headers from fetch response.
|
|
export class CookieJar {
|
|
constructor() { this.jar = new Map(); }
|
|
ingest(response) {
|
|
const raw = response.headers.getSetCookie?.() || (response.headers.get("set-cookie") ? [response.headers.get("set-cookie")] : []);
|
|
for (const line of raw) {
|
|
const [pair] = line.split(";");
|
|
const eq = pair.indexOf("=");
|
|
if (eq > 0) this.jar.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
|
|
}
|
|
}
|
|
header() {
|
|
return [...this.jar.entries()].map(([k, v]) => `${k}=${v}`).join("; ");
|
|
}
|
|
get(name) { return this.jar.get(name); }
|
|
set(name, value) { this.jar.set(name, value); }
|
|
clear() { this.jar.clear(); }
|
|
}
|
|
|
|
export async function jsonReq(method, url, { jar, body, headers = {} } = {}) {
|
|
const opts = { method, headers: { ...headers } };
|
|
if (jar) opts.headers["Cookie"] = jar.header();
|
|
if (body !== undefined) {
|
|
opts.body = JSON.stringify(body);
|
|
opts.headers["Content-Type"] = "application/json";
|
|
}
|
|
const r = await fetch(url, opts);
|
|
if (jar) jar.ingest(r);
|
|
let data = null;
|
|
const txt = await r.text();
|
|
try { data = txt ? JSON.parse(txt) : null; } catch { data = txt; }
|
|
return { status: r.status, ok: r.ok, data, headers: r.headers };
|
|
}
|
|
|
|
// v1.2 single-session: pool is loaded at startup from $QUIZ_POOL_PATH and sid
|
|
// is fixed (default "main"). setupSession() now just authenticates the admin
|
|
// and resets the canonical session so each scenario starts from the lobby.
|
|
// The `pool` arg is accepted but unused; kept so call sites stay readable
|
|
// (pool is set at bootServer time, not per-scenario).
|
|
export async function setupSession(serverUrl, adminPw, _poolUnused) {
|
|
const jar = new CookieJar();
|
|
const login = await jsonReq("POST", `${serverUrl}/admin/login`, { jar, body: { password: adminPw } });
|
|
if (!login.ok) throw new Error(`admin login failed: ${login.status} ${JSON.stringify(login.data)}`);
|
|
const reset = await jsonReq("POST", `${serverUrl}/admin/api/reset`, { jar, body: {} });
|
|
if (!reset.ok) throw new Error(`reset failed: ${reset.status} ${JSON.stringify(reset.data)}`);
|
|
return { sid: "main", jar };
|
|
}
|
|
|
|
// Student wrapper: join + connect WS + collect messages.
|
|
export class Student {
|
|
constructor(serverUrl, sid, studentId, name) {
|
|
this.serverUrl = serverUrl;
|
|
this.sid = sid;
|
|
this.studentId = studentId;
|
|
this.name = name;
|
|
this.jar = new CookieJar();
|
|
this.ws = null;
|
|
this.messages = [];
|
|
this.lastMsgByType = {};
|
|
this.events = new EventTarget();
|
|
this.closed = false;
|
|
}
|
|
async join() {
|
|
const r = await jsonReq("POST", `${this.serverUrl}/api/session/${this.sid}/join`, {
|
|
jar: this.jar,
|
|
body: { student_id: this.studentId, name: this.name },
|
|
});
|
|
if (!r.ok) throw new Error(`student join failed: ${r.status} ${JSON.stringify(r.data)}`);
|
|
return r;
|
|
}
|
|
connect() {
|
|
return new Promise((resolve, reject) => {
|
|
const wsUrl = this.serverUrl.replace(/^http/, "ws") + `/ws/student/${this.sid}`;
|
|
const headers = { Cookie: this.jar.header() };
|
|
this.ws = new WebSocket(wsUrl, { headers });
|
|
this.ws.on("open", () => resolve());
|
|
this.ws.on("message", buf => {
|
|
let msg;
|
|
try { msg = JSON.parse(buf.toString()); } catch { return; }
|
|
this.messages.push({ ts: nowMs(), msg });
|
|
this.lastMsgByType[msg.type] = msg;
|
|
this.events.dispatchEvent(new CustomEvent("msg", { detail: msg }));
|
|
});
|
|
this.ws.on("close", (code, reason) => {
|
|
this.closed = true;
|
|
this.events.dispatchEvent(new CustomEvent("close", { detail: { code, reason: reason.toString() } }));
|
|
});
|
|
this.ws.on("error", err => reject(err));
|
|
});
|
|
}
|
|
// Wait until a NEW message of the given type arrives (does not use cache).
|
|
// Use lastMsgByType[type] to inspect cached values without waiting.
|
|
waitFor(type, { timeoutMs = 5000, useCache = false } = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
if (useCache && this.lastMsgByType[type]) return resolve(this.lastMsgByType[type]);
|
|
const handler = ev => {
|
|
if (ev.detail?.type === type) {
|
|
this.events.removeEventListener("msg", handler);
|
|
clearTimeout(timer);
|
|
resolve(ev.detail);
|
|
}
|
|
};
|
|
const timer = setTimeout(() => {
|
|
this.events.removeEventListener("msg", handler);
|
|
reject(new Error(`timed out waiting for WS type=${type} after ${timeoutMs}ms`));
|
|
}, timeoutMs);
|
|
this.events.addEventListener("msg", handler);
|
|
});
|
|
}
|
|
send(obj) {
|
|
if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(obj));
|
|
}
|
|
submit(qIdx, answer) {
|
|
this.send({ type: "submit", question_idx: qIdx, answer });
|
|
}
|
|
disconnect() {
|
|
if (this.ws && !this.closed) {
|
|
try { this.ws.terminate(); } catch {}
|
|
}
|
|
}
|
|
async reconnect() {
|
|
this.closed = false;
|
|
this.lastMsgByType = {};
|
|
await this.connect();
|
|
}
|
|
}
|
|
|
|
// Admin WS wrapper.
|
|
export class Admin {
|
|
constructor(serverUrl, sid, jar) {
|
|
this.serverUrl = serverUrl;
|
|
this.sid = sid;
|
|
this.jar = jar;
|
|
this.ws = null;
|
|
this.messages = [];
|
|
this.events = new EventTarget();
|
|
}
|
|
connect() {
|
|
return new Promise((resolve, reject) => {
|
|
const wsUrl = this.serverUrl.replace(/^http/, "ws") + `/ws/instructor/${this.sid}`;
|
|
this.ws = new WebSocket(wsUrl, { headers: { Cookie: this.jar.header() } });
|
|
this.ws.on("open", () => resolve());
|
|
this.ws.on("message", buf => {
|
|
let msg;
|
|
try { msg = JSON.parse(buf.toString()); } catch { return; }
|
|
this.messages.push({ ts: nowMs(), msg });
|
|
this.events.dispatchEvent(new CustomEvent("msg", { detail: msg }));
|
|
});
|
|
this.ws.on("error", err => reject(err));
|
|
});
|
|
}
|
|
open(qIdx, timeLimit = 60) { this.ws.send(JSON.stringify({ type: "open_question", question_idx: qIdx, time_limit: timeLimit })); }
|
|
close() { this.ws.send(JSON.stringify({ type: "close_question" })); }
|
|
next() { this.ws.send(JSON.stringify({ type: "next" })); }
|
|
end() { this.ws.send(JSON.stringify({ type: "end_session" })); }
|
|
waitFor(type, { timeoutMs = 5000 } = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const handler = ev => {
|
|
if (ev.detail?.type === type) {
|
|
this.events.removeEventListener("msg", handler);
|
|
clearTimeout(timer);
|
|
resolve(ev.detail);
|
|
}
|
|
};
|
|
const timer = setTimeout(() => {
|
|
this.events.removeEventListener("msg", handler);
|
|
reject(new Error(`admin timed out waiting for type=${type} after ${timeoutMs}ms`));
|
|
}, timeoutMs);
|
|
this.events.addEventListener("msg", handler);
|
|
});
|
|
}
|
|
disconnect() { try { this.ws?.terminate(); } catch {} }
|
|
}
|
|
|
|
// A small fixed pool used for stress runs.
|
|
export const STRESS_POOL = {
|
|
title: "Stress Pool",
|
|
score_fn: "linear_decay",
|
|
time_limit_default: 10,
|
|
questions: [
|
|
{ id: "s1", text: "2+2?", options: { A: "3", B: "4", C: "5", D: "6" }, correct: "B", explanation: "" },
|
|
{ id: "s2", text: "Capital of France?", options: { A: "Berlin", B: "Madrid", C: "Paris", D: "Rome" }, correct: "C", explanation: "" },
|
|
{ id: "s3", text: "Fastest sort?", options: { A: "Bubble", B: "Quick", C: "Insertion", D: "Selection" }, correct: "B", explanation: "" },
|
|
{ id: "s4", text: "HTTP code for not found?", options: { A: "200", B: "301", C: "404", D: "500" }, correct: "C", explanation: "" },
|
|
{ id: "s5", text: "Speed of light (m/s)?", options: { A: "3e8", B: "3e6", C: "1.5e8", D: "9.8" }, correct: "A", explanation: "" },
|
|
],
|
|
};
|
|
|
|
export { sleep };
|