Files
quiz/tests/stress/api_stress.mjs
ameer 55ecb1b396 fix(stress): port harnesses to v1.2 single-session API + remove WS-batch hang
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.
2026-05-03 04:16:23 +08:00

473 lines
20 KiB
JavaScript

// API-level adversarial stress tests for the quiz portal.
// Each scenario boots a fresh server on its own port, runs assertions,
// and logs JSON lines to stdout. Designed to be run repeatedly with
// different seeds; see run_loop.sh for the wrapper.
import { bootServer, setupSession, Student, Admin, STRESS_POOL, sleep, logLine, rand, pickRandom } from "./lib.mjs";
import WebSocket from "ws";
const SEED = parseInt(process.argv[2] || Date.now(), 10);
const PORT = parseInt(process.argv[3] || (8200 + (SEED % 100)), 10);
let mulberry32 = (s) => () => {
s |= 0; s = s + 0x6D2B79F5 | 0;
let t = Math.imul(s ^ s >>> 15, 1 | s);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
const rng = mulberry32(SEED);
let pass = 0, fail = 0, warn = 0;
const failures = [];
function expect(cond, scenario, msg, extra = {}) {
if (cond) { pass++; logLine(scenario, "pass", msg, extra); }
else { fail++; failures.push({ scenario, msg, extra }); logLine(scenario, "fail", msg, extra); }
}
function note(scenario, msg, extra = {}) { warn++; logLine(scenario, "note", msg, extra); }
async function runScenario(name, fn) {
logLine(name, "start", `seed=${SEED}`);
let server;
try {
server = await bootServer({ port: PORT });
await fn(server);
logLine(name, "ok", "scenario completed");
} catch (err) {
fail++;
failures.push({ scenario: name, msg: "uncaught", extra: { err: err.message, stack: err.stack?.slice(0, 600) } });
logLine(name, "fail", "uncaught exception", { err: err.message });
} finally {
if (server) await server.stop();
}
}
// ---------- Scenarios ----------
async function happyPath(server) {
const { sid, jar } = await setupSession(server.url, server.adminPw, STRESS_POOL);
const N = 20;
const students = await Promise.all(Array.from({ length: N }, async (_, i) => {
const s = new Student(server.url, sid, `S${i.toString().padStart(3, "0")}`, `Student${i}`);
await s.join();
await s.connect();
return s;
}));
const admin = new Admin(server.url, sid, jar);
await admin.connect();
// Don't wait on lobby_update from the snapshot; that's a race
// (snapshot dispatch can land before the listener attaches). The
// first thing we DO act on (a question_open we triggered) is a
// sufficient liveness signal for the admin WS.
for (let q = 0; q < STRESS_POOL.questions.length; q++) {
// Pre-register waiters BEFORE triggering the broadcast so we don't
// lose the message in the race window.
const studentOpenWaits = students.map(s => s.waitFor("question_open"));
const adminOpenWait = admin.waitFor("question_open");
// v1.2: advance_to_next handles the whole lifecycle (close prev +
// open next). Use open() only for the very first question from
// the lobby state.
if (q === 0) admin.open(q, 5);
else admin.next();
await adminOpenWait;
await Promise.all(studentOpenWaits);
// Each student picks a random answer (mostly correct)
await Promise.all(students.map(async (s, i) => {
try {
await sleep(rand(50, 800));
const correct = STRESS_POOL.questions[q].correct;
const ans = rng() < 0.7 ? correct : pickRandom(["A","B","C","D"]);
const ackWait = s.waitFor("submit_ack", { timeoutMs: 3000 });
s.submit(q, ans);
const ack = await ackWait;
expect(ack.question_idx === q, "happy", `student${i} q${q} ack idx==q`, { ack_idx: ack.question_idx, expected: q });
} catch (e) {
note("happy", `student${i} q${q}: ${e.message}`);
}
}));
// Only manually verify question_closed on the LAST question;
// intermediate closes happen implicitly inside admin.next() and
// do broadcast a question_closed, but we don't need to gate on it.
if (q === STRESS_POOL.questions.length - 1) {
const studentClosedWaits = students.map(s => s.waitFor("question_closed", { timeoutMs: 3000 }).catch(() => null));
const adminClosedWait = admin.waitFor("question_closed", { timeoutMs: 3000 });
admin.close();
await adminClosedWait;
await Promise.all(studentClosedWaits);
}
}
const sessionEndedWait = admin.waitFor("session_ended", { timeoutMs: 3000 });
admin.end();
await sessionEndedWait;
expect(true, "happy", "session ended cleanly");
students.forEach(s => s.disconnect());
admin.disconnect();
}
async function lateJoiners(server) {
const { sid, jar } = await setupSession(server.url, server.adminPw, STRESS_POOL);
const admin = new Admin(server.url, sid, jar);
await admin.connect();
// 5 students join in lobby
const early = await Promise.all([0,1,2,3,4].map(async i => {
const s = new Student(server.url, sid, `E${i}`, `Early${i}`);
await s.join(); await s.connect(); return s;
}));
const adminOpenWait = admin.waitFor("question_open");
admin.open(0, 8);
await adminOpenWait;
await sleep(2000);
// 3 late joiners
for (let i = 0; i < 3; i++) {
const s = new Student(server.url, sid, `L${i}`, `Late${i}`);
await s.join();
// Pre-register waiters BEFORE connect so we catch the snapshot on connect
const stateWait = s.waitFor("state");
const qopenWait = s.waitFor("question_open", { timeoutMs: 2000 });
await s.connect();
const m = await qopenWait.catch(() => null);
if (!m) { fail++; failures.push({ scenario: "late_join", msg: `late${i} got no question_open on connect`, extra: {} }); logLine("late_join", "fail", `late${i} got no question_open on connect`); continue; }
expect(m.question_idx === 0, "late_join", `late${i} sees correct idx`);
expect(m.remaining_ms < 8000, "late_join", `late${i} remaining_ms reduced`, { remaining_ms: m.remaining_ms });
expect(m.remaining_ms > 0, "late_join", `late${i} remaining_ms > 0`, { remaining_ms: m.remaining_ms });
const ackWait = s.waitFor("submit_ack", { timeoutMs: 3000 });
s.submit(0, STRESS_POOL.questions[0].correct);
const ack = await ackWait.catch(() => null);
expect(ack && ack.score > 0, "late_join", `late${i} got positive score from late submit`, { score: ack?.score });
s.disconnect();
}
const adminClosedWait = admin.waitFor("question_closed");
admin.close();
await adminClosedWait;
early.forEach(s => s.disconnect());
admin.disconnect();
}
async function disconnectMidQuestion(server) {
const { sid, jar } = await setupSession(server.url, server.adminPw, STRESS_POOL);
const admin = new Admin(server.url, sid, jar);
await admin.connect();
const s = new Student(server.url, sid, "D1", "Drop");
await s.join();
const stateWaitInitial = s.waitFor("state");
await s.connect();
await stateWaitInitial;
const sQopenWait = s.waitFor("question_open");
const aOpenWait = admin.waitFor("question_open");
admin.open(0, 10);
await aOpenWait;
await sQopenWait;
s.disconnect();
await sleep(500);
// While dropped, instructor closes
const closedWait = admin.waitFor("question_closed");
admin.close();
await closedWait;
// Reconnect — should get state=question_closed (or current state)
const reconnectStateWait = s.waitFor("state", { timeoutMs: 2000 });
await s.reconnect();
const state = await reconnectStateWait;
expect(["question_closed", "between_questions", "lobby"].includes(state.state), "disconnect_midq", `state on reconnect = ${state.state}`);
s.disconnect();
admin.disconnect();
}
// THE KEY scenario from the user: phone screen sleeps mid-quiz, instructor
// advances to a new question, phone wakes — the student MUST see the
// LATEST question, not a stale screen.
async function sleepWakeNextQuestion(server) {
const { sid, jar } = await setupSession(server.url, server.adminPw, STRESS_POOL);
const admin = new Admin(server.url, sid, jar);
await admin.connect();
const s = new Student(server.url, sid, "SW1", "SleepWake");
await s.join();
const initState = s.waitFor("state");
await s.connect();
await initState;
// Open Q0
let sWait = s.waitFor("question_open");
let aWait = admin.waitFor("question_open");
admin.open(0, 5);
await aWait; await sWait;
await sleep(500);
const ackWait = s.waitFor("submit_ack");
s.submit(0, "B");
await ackWait;
// Phone "sleeps" — drop WS hard
s.disconnect();
await sleep(800);
// Instructor closes, advances, opens Q2 (skip Q1 to make wake state non-trivial)
let closedW = admin.waitFor("question_closed");
admin.close();
await closedW;
admin.next();
await sleep(150);
aWait = admin.waitFor("question_open");
admin.open(2, 8);
await aWait;
await sleep(300);
// Phone wakes — reconnect; pre-register both expected events
const stateOnWake = s.waitFor("state", { timeoutMs: 2000 });
const qopenOnWake = s.waitFor("question_open", { timeoutMs: 2000 });
await s.reconnect();
const state = await stateOnWake;
expect(state.state === "question_open", "sleep_wake", `state on wake = ${state.state}`, { state });
expect(state.current_question_idx === 2, "sleep_wake", `current_question_idx on wake = ${state.current_question_idx}`);
const qopen = await qopenOnWake.catch(() => null);
expect(qopen && qopen.question_idx === 2, "sleep_wake", "reconnect emits question_open for current idx", { qopen_idx: qopen?.question_idx });
expect(qopen && qopen.text === STRESS_POOL.questions[2].text, "sleep_wake", "question text matches latest Q");
if (qopen) {
const ack2W = s.waitFor("submit_ack", { timeoutMs: 3000 });
s.submit(2, "B");
const ack = await ack2W.catch(() => null);
expect(ack && ack.question_idx === 2, "sleep_wake", "submit on Q2 after wake succeeded", { ack_idx: ack?.question_idx });
}
s.disconnect();
admin.disconnect();
}
// Cookie tampering: try to flip student_id by mangling the signed cookie
async function cookieTampering(server) {
const { sid } = await setupSession(server.url, server.adminPw, STRESS_POOL);
const s = new Student(server.url, sid, "ORIG", "Original");
await s.join();
// Tamper: append a junk byte
const original = s.jar.get("qz_student");
s.jar.set("qz_student", original + "X");
// Try to connect WS
let connected = false, closeCode = null;
try {
await new Promise((res, rej) => {
const wsUrl = server.url.replace(/^http/, "ws") + `/ws/student/${sid}`;
const w = new WebSocket(wsUrl, { headers: { Cookie: s.jar.header() } });
w.on("open", () => { connected = true; w.close(); res(); });
w.on("close", (c) => { closeCode = c; res(); });
w.on("error", () => res());
setTimeout(res, 1500);
});
} catch {}
expect(!connected || closeCode === 4001 || closeCode === 1006 || closeCode === 1008, "cookie_tamper", "tampered cookie rejected on WS", { connected, closeCode });
// Reset cookie and retry
s.jar.set("qz_student", original);
await s.connect();
await s.waitFor("state");
expect(true, "cookie_tamper", "valid cookie still works after tamper attempt");
s.disconnect();
}
// Cross-session cookie: in v1.2 the server hosts a SINGLE canonical session
// ("main"), so cross-session reuse isn't a topology that exists at runtime.
// We instead assert the closest single-session analog: a cookie issued for
// sid="main" is rejected when used against a non-existent sid path.
async function crossSessionCookie(server) {
const { sid: sidA } = await setupSession(server.url, server.adminPw, STRESS_POOL);
const s = new Student(server.url, sidA, "X1", "CrossUser");
await s.join();
const bogusSid = "not-a-real-session";
const wsUrl = server.url.replace(/^http/, "ws") + `/ws/student/${bogusSid}`;
let opened = false, closeCode = null;
await new Promise(res => {
const w = new WebSocket(wsUrl, { headers: { Cookie: s.jar.header() } });
w.on("open", () => { opened = true; w.close(); res(); });
w.on("close", (c) => { closeCode = c; res(); });
w.on("error", () => res());
setTimeout(res, 1500);
});
expect(!opened, "cross_session", "cookie not honored against non-existent sid", { opened, closeCode });
}
// Duplicate student_id: two browsers join with same student_id (different cookies)
async function duplicateStudentId(server) {
const { sid } = await setupSession(server.url, server.adminPw, STRESS_POOL);
const s1 = new Student(server.url, sid, "DUP", "FirstSession");
const s2 = new Student(server.url, sid, "DUP", "SecondSession");
await s1.join();
const s1State = s1.waitFor("state");
await s1.connect();
await s1State;
await s2.join();
const s2State = s2.waitFor("state");
await s2.connect();
await s2State;
expect(s1.ws.readyState === WebSocket.OPEN, "dup_id", "first DUP session open");
expect(s2.ws.readyState === WebSocket.OPEN, "dup_id", "second DUP session open");
s1.disconnect(); s2.disconnect();
}
// Submit out-of-order / wrong question_idx
async function badSubmits(server) {
const { sid, jar } = await setupSession(server.url, server.adminPw, STRESS_POOL);
const admin = new Admin(server.url, sid, jar); await admin.connect();
const s = new Student(server.url, sid, "BAD", "Bad");
await s.join();
const initState = s.waitFor("state");
await s.connect();
await initState;
// Submit before any question opened
s.submit(0, "A");
await sleep(300);
expect(!s.lastMsgByType.submit_ack, "bad_submit", "submit before open ignored / no ack", { last: Object.keys(s.lastMsgByType) });
let sWait = s.waitFor("question_open");
let aWait = admin.waitFor("question_open");
admin.open(0, 5);
await aWait; await sWait;
// Wrong idx submit (give it 600 ms to arrive if it does)
const ackBefore = s.lastMsgByType.submit_ack;
s.submit(99, "A");
await sleep(600);
const ackAfter = s.lastMsgByType.submit_ack;
expect(ackAfter === ackBefore || (ackAfter && ackAfter.question_idx !== 99), "bad_submit", "wrong-idx submit not acked", { ackAfter });
// Valid submit
const okWait = s.waitFor("submit_ack", { timeoutMs: 2000 });
s.submit(0, "B");
const ok = await okWait;
expect(ok.question_idx === 0, "bad_submit", "valid submit acked");
// Resubmit (already submitted) — should NOT change stored answer
s.submit(0, "A");
await sleep(400);
const closedWait = s.waitFor("question_closed");
admin.close();
const closed = await closedWait;
expect(closed.your_answer === "B", "bad_submit", "your_answer remained 'B' after resubmit attempt", { your_answer: closed.your_answer });
s.disconnect(); admin.disconnect();
}
// Race: many students submit at the moment of close (within ms)
async function closeBoundaryRace(server) {
const { sid, jar } = await setupSession(server.url, server.adminPw, STRESS_POOL);
const admin = new Admin(server.url, sid, jar); await admin.connect();
const N = 30;
const students = await Promise.all(Array.from({ length: N }, async (_, i) => {
const s = new Student(server.url, sid, `R${i}`, `Race${i}`);
await s.join(); await s.connect(); return s;
}));
// Pre-register everyone's question_open + question_closed waits
const sOpens = students.map(s => s.waitFor("question_open", { timeoutMs: 3000 }).catch(() => null));
const aOpen = admin.waitFor("question_open", { timeoutMs: 3000 });
// Pre-register the closed wait too — auto-close fires even without manual close()
const aClosed = admin.waitFor("question_closed", { timeoutMs: 6000 });
admin.open(0, 1); // 1-second window — auto-close should fire ~1s later
await aOpen;
await Promise.all(sOpens);
// Have all students fire submit at random times spanning the window edge
const fires = students.map(async (s, i) => {
const ackW = s.waitFor("submit_ack", { timeoutMs: 2000 });
await sleep(rand(800, 1200)); // some fire after auto-close
s.submit(0, "B");
return ackW.catch(() => null);
});
const acks = await Promise.all(fires);
const acked = acks.filter(Boolean).length;
const closed = await aClosed.catch(() => null);
logLine("close_race", "info", `race results`, { acked, total: N, hist: closed?.histogram });
expect(closed !== null, "close_race", "question_closed broadcast received (auto-close or manual)");
expect(acked >= 1 && acked <= N, "close_race", "no crash, some submits succeeded");
students.forEach(s => s.disconnect()); admin.disconnect();
}
// Fuzz: malformed messages from a student WS
async function fuzzMessages(server) {
const { sid, jar } = await setupSession(server.url, server.adminPw, STRESS_POOL);
const admin = new Admin(server.url, sid, jar); await admin.connect();
// First: a sentinel student that does NOT receive fuzz, to verify global server health
const sentinel = new Student(server.url, sid, "SENT", "Sentinel");
await sentinel.join();
const sentinelStateW = sentinel.waitFor("state");
await sentinel.connect();
await sentinelStateW;
// Second: a fuzz student that gets garbage shoved at it
const s = new Student(server.url, sid, "FZ", "Fuzz");
await s.join();
const fzStateW = s.waitFor("state");
await s.connect();
await fzStateW;
const garbages = [
"not json",
"{}",
JSON.stringify({ type: "open_question" }), // student trying to act as instructor
JSON.stringify({ type: "submit", question_idx: -1, answer: "Z" }),
JSON.stringify({ type: "submit", question_idx: 0, answer: { nested: "obj" } }),
JSON.stringify({ type: "💀" }),
"x".repeat(50_000),
];
for (const g of garbages) { try { s.ws.send(g); } catch {} ; await sleep(50); }
await sleep(500);
// Server should still serve OTHER clients regardless of what happened to fuzz student.
const sentOpenW = sentinel.waitFor("question_open", { timeoutMs: 2000 });
const adminOpenW = admin.waitFor("question_open", { timeoutMs: 2000 });
admin.open(0, 3);
await adminOpenW;
const sm = await sentOpenW.catch(() => null);
expect(sm && sm.question_idx === 0, "fuzz", "OTHER clients still served after fuzz on one student", { got: !!sm });
// Did the fuzz student survive? (informational, not asserted as pass/fail)
const survived = !s.closed && s.ws.readyState === WebSocket.OPEN;
logLine("fuzz", "info", `fuzz student survival`, { survived, ws_state: s.ws.readyState });
if (!survived) note("fuzz", "fuzz student WS was killed by malformed input — server lacks JSON-decode try/except in WS loop (codex room.py student_ws line ~87)");
const adminClosedW = admin.waitFor("question_closed", { timeoutMs: 5000 });
admin.close();
await adminClosedW.catch(() => null);
s.disconnect(); sentinel.disconnect(); admin.disconnect();
}
// Repeated rapid connect/disconnect (simulating flaky network)
async function flakyReconnect(server) {
const { sid, jar } = await setupSession(server.url, server.adminPw, STRESS_POOL);
const admin = new Admin(server.url, sid, jar); await admin.connect();
const s = new Student(server.url, sid, "FK", "Flaky");
await s.join();
for (let i = 0; i < 10; i++) {
const stateW = s.waitFor("state", { timeoutMs: 2000 });
await s.connect();
await stateW.catch(() => {});
s.disconnect();
await sleep(rand(50, 200));
}
// Final reconnect
const finalStateW = s.waitFor("state", { timeoutMs: 2000 });
await s.connect();
await finalStateW;
const sQopenW = s.waitFor("question_open");
const aQopenW = admin.waitFor("question_open");
admin.open(0, 5);
await aQopenW; await sQopenW;
const ackW = s.waitFor("submit_ack", { timeoutMs: 3000 });
s.submit(0, "B");
const ack = await ackW;
expect(ack !== null, "flaky_reconnect", "post-flaky student can still submit");
const closedW = admin.waitFor("question_closed");
admin.close();
await closedW;
s.disconnect(); admin.disconnect();
}
const SCENARIOS = {
happy: happyPath,
late_join: lateJoiners,
disconnect_midq: disconnectMidQuestion,
sleep_wake: sleepWakeNextQuestion,
cookie_tamper: cookieTampering,
cross_session: crossSessionCookie,
dup_id: duplicateStudentId,
bad_submit: badSubmits,
close_race: closeBoundaryRace,
fuzz: fuzzMessages,
flaky_reconnect: flakyReconnect,
};
// Pick scenario subset based on env or run all in random order
const wanted = process.env.SCENARIOS
? process.env.SCENARIOS.split(",")
: Object.keys(SCENARIOS).sort(() => rng() - 0.5);
logLine("runner", "info", `starting api stress`, { seed: SEED, port: PORT, scenarios: wanted });
for (const name of wanted) {
const fn = SCENARIOS[name];
if (!fn) { logLine("runner", "warn", `unknown scenario ${name}`); continue; }
await runScenario(name, fn);
}
logLine("runner", "summary", `done`, { pass, fail, warn, failures });
process.exit(fail > 0 ? 1 : 0);