|
import Kernel from "@onkernel/sdk"; |
|
import { chromium } from "playwright-core"; |
|
|
|
const TARGET_URL = process.env.TARGET_URL || "https://kernel.sh"; |
|
const SCENARIO = process.env.SCENARIO || "all"; |
|
const kernel = new Kernel(); |
|
|
|
async function vmExec(sessionId, command) { |
|
try { |
|
const res = await kernel.browsers.process.exec(sessionId, { command: "bash", args: ["-c", command], timeout_sec: 10 }); |
|
return Buffer.from(res.stdout_b64 || "", "base64").toString(); |
|
} catch { |
|
return "(exec failed)"; |
|
} |
|
} |
|
|
|
async function vmStats(sessionId) { |
|
const mem = await vmExec(sessionId, "free -m | grep Mem"); |
|
const load = await vmExec(sessionId, "cat /proc/loadavg"); |
|
console.log(` [VM] load: ${load.trim()} | ${mem.trim()}`); |
|
} |
|
|
|
// Connect a new CDP client, return { browser, page, id } |
|
async function connectCDP(cdpWsUrl, id) { |
|
const browser = await chromium.connectOverCDP(cdpWsUrl); |
|
const ctx = browser.contexts()[0]; |
|
const page = ctx.pages()[0] || (await ctx.newPage()); |
|
return { browser, page, id }; |
|
} |
|
|
|
// ── Scenario 1: N connections, all fire screenshots simultaneously ── |
|
async function scenarioParallelScreenshots(cdpWsUrl, n = 5) { |
|
console.log(`\n═══ Parallel screenshots: ${n} CDP connections, all screenshotting ═══`); |
|
|
|
const conns = []; |
|
for (let i = 0; i < n; i++) { |
|
conns.push(await connectCDP(cdpWsUrl, i)); |
|
} |
|
console.log(` ${n} connections established`); |
|
|
|
const deadSet = new Set(); |
|
for (const c of conns) { |
|
c.browser.on("disconnected", () => { |
|
deadSet.add(c.id); |
|
console.log(` 🔴 Connection ${c.id} DISCONNECTED (${deadSet.size}/${n} dead)`); |
|
}); |
|
} |
|
|
|
// All connections fire screenshots simultaneously for 30s |
|
const duration = 30_000; |
|
const start = Date.now(); |
|
|
|
const workers = conns.map(async (c) => { |
|
let ok = 0, fail = 0; |
|
while (Date.now() - start < duration && !deadSet.has(c.id)) { |
|
try { |
|
await c.page.screenshot({ type: "jpeg", quality: 50, timeout: 5000 }); |
|
ok++; |
|
} catch (err) { |
|
fail++; |
|
const msg = err.message.split("\n")[0].slice(0, 80); |
|
if (fail <= 3 || fail % 10 === 0) { |
|
console.log(` [conn-${c.id}] fail #${fail}: ${msg}`); |
|
} |
|
if (err.message.includes("closed") || err.message.includes("crashed") || err.message.includes("Target")) { |
|
return { id: c.id, ok, fail, dead: true }; |
|
} |
|
} |
|
} |
|
return { id: c.id, ok, fail, dead: deadSet.has(c.id) }; |
|
}); |
|
|
|
const results = await Promise.all(workers); |
|
for (const r of results) { |
|
console.log(` [conn-${r.id}] ${r.ok} ok, ${r.fail} fail, dead=${r.dead}`); |
|
} |
|
|
|
const dropped = deadSet.size > 0; |
|
for (const c of conns) { |
|
try { await c.browser.close(); } catch {} |
|
} |
|
|
|
return dropped; |
|
} |
|
|
|
// ── Scenario 2: N connections, mixed commands (evaluate, screenshot, navigate) firing concurrently ── |
|
async function scenarioMixedCommands(cdpWsUrl, n = 4) { |
|
console.log(`\n═══ Mixed commands: ${n} connections firing evaluate+screenshot+navigate ═══`); |
|
|
|
const conns = []; |
|
for (let i = 0; i < n; i++) { |
|
conns.push(await connectCDP(cdpWsUrl, i)); |
|
} |
|
console.log(` ${n} connections established`); |
|
|
|
const deadSet = new Set(); |
|
for (const c of conns) { |
|
c.browser.on("disconnected", () => { |
|
deadSet.add(c.id); |
|
console.log(` 🔴 Connection ${c.id} DISCONNECTED`); |
|
}); |
|
} |
|
|
|
const duration = 30_000; |
|
const start = Date.now(); |
|
|
|
const workers = conns.map(async (c) => { |
|
let ok = 0, fail = 0; |
|
const commands = ["screenshot", "evaluate", "title", "evaluate-heavy"]; |
|
let cmdIdx = 0; |
|
|
|
while (Date.now() - start < duration && !deadSet.has(c.id)) { |
|
const cmd = commands[cmdIdx % commands.length]; |
|
cmdIdx++; |
|
try { |
|
switch (cmd) { |
|
case "screenshot": |
|
await c.page.screenshot({ type: "jpeg", quality: 50, timeout: 3000 }); |
|
break; |
|
case "evaluate": |
|
await c.page.evaluate(() => document.title); |
|
break; |
|
case "title": |
|
await c.page.title(); |
|
break; |
|
case "evaluate-heavy": |
|
await c.page.evaluate(() => { |
|
const divs = document.querySelectorAll("div"); |
|
for (const d of divs) void d.innerText; |
|
return divs.length; |
|
}); |
|
break; |
|
} |
|
ok++; |
|
} catch (err) { |
|
fail++; |
|
const msg = err.message.split("\n")[0].slice(0, 80); |
|
if (fail <= 5 || fail % 20 === 0) { |
|
console.log(` [conn-${c.id}] ${cmd} fail #${fail}: ${msg}`); |
|
} |
|
if (err.message.includes("closed") || err.message.includes("crashed") || err.message.includes("Target")) { |
|
return { id: c.id, ok, fail, dead: true }; |
|
} |
|
} |
|
// No delay — fire as fast as possible |
|
} |
|
return { id: c.id, ok, fail, dead: deadSet.has(c.id) }; |
|
}); |
|
|
|
const results = await Promise.all(workers); |
|
for (const r of results) { |
|
console.log(` [conn-${r.id}] ${r.ok} ok, ${r.fail} fail, dead=${r.dead}`); |
|
} |
|
|
|
const dropped = deadSet.size > 0; |
|
for (const c of conns) { |
|
try { await c.browser.close(); } catch {} |
|
} |
|
|
|
return dropped; |
|
} |
|
|
|
// ── Scenario 3: One connection blocks, others fire rapid commands ── |
|
async function scenarioBlockerPlusFlood(cdpWsUrl) { |
|
console.log("\n═══ Blocker + flood: 1 connection blocks 20s, 3 others fire rapid commands ═══"); |
|
|
|
const blocker = await connectCDP(cdpWsUrl, "blocker"); |
|
const flooders = []; |
|
for (let i = 0; i < 3; i++) { |
|
flooders.push(await connectCDP(cdpWsUrl, `flood-${i}`)); |
|
} |
|
console.log(" 4 connections established (1 blocker + 3 flooders)"); |
|
|
|
const deadSet = new Set(); |
|
blocker.browser.on("disconnected", () => { |
|
deadSet.add("blocker"); |
|
console.log(" 🔴 Blocker connection DISCONNECTED"); |
|
}); |
|
for (const f of flooders) { |
|
f.browser.on("disconnected", () => { |
|
deadSet.add(f.id); |
|
console.log(` 🔴 ${f.id} DISCONNECTED`); |
|
}); |
|
} |
|
|
|
// Start the block |
|
const blockStart = Date.now(); |
|
const blockPromise = blocker.page |
|
.evaluate(() => { |
|
const end = Date.now() + 20000; |
|
while (Date.now() < end) Math.random(); |
|
return "done"; |
|
}) |
|
.then(() => console.log(` [blocker] evaluate returned in ${Date.now() - blockStart}ms`)) |
|
.catch((err) => console.log(` [blocker] evaluate THREW after ${Date.now() - blockStart}ms: ${err.message.split("\n")[0].slice(0, 100)}`)); |
|
|
|
// Flooders fire commands as fast as possible |
|
const floodDuration = 25_000; |
|
const floodStart = Date.now(); |
|
|
|
const floodWorkers = flooders.map(async (f) => { |
|
let ok = 0, fail = 0; |
|
while (Date.now() - floodStart < floodDuration && !deadSet.has(f.id)) { |
|
try { |
|
// Alternate between screenshot and evaluate |
|
if (ok % 2 === 0) { |
|
await f.page.screenshot({ type: "jpeg", quality: 50, timeout: 3000 }); |
|
} else { |
|
await f.page.evaluate(() => { |
|
void document.querySelectorAll("*").length; |
|
return Date.now(); |
|
}); |
|
} |
|
ok++; |
|
} catch (err) { |
|
fail++; |
|
const msg = err.message.split("\n")[0].slice(0, 80); |
|
if (fail <= 3 || fail % 10 === 0) { |
|
const elapsed = ((Date.now() - floodStart) / 1000).toFixed(1); |
|
console.log(` [${f.id}] fail #${fail} at ${elapsed}s: ${msg}`); |
|
} |
|
if (err.message.includes("closed") || err.message.includes("crashed") || err.message.includes("Target")) { |
|
return { id: f.id, ok, fail, dead: true }; |
|
} |
|
} |
|
} |
|
return { id: f.id, ok, fail, dead: deadSet.has(f.id) }; |
|
}); |
|
|
|
const [floodResults] = await Promise.all([Promise.all(floodWorkers), blockPromise]); |
|
|
|
for (const r of floodResults) { |
|
console.log(` [${r.id}] ${r.ok} ok, ${r.fail} fail, dead=${r.dead}`); |
|
} |
|
console.log(` [blocker] dead=${deadSet.has("blocker")}`); |
|
|
|
const dropped = deadSet.size > 0; |
|
try { await blocker.browser.close(); } catch {} |
|
for (const f of flooders) { |
|
try { await f.browser.close(); } catch {} |
|
} |
|
|
|
return dropped; |
|
} |
|
|
|
// ── Scenario 4: Rapid connect/disconnect cycling while commands are in flight ── |
|
async function scenarioConnectDisconnectCycle(cdpWsUrl) { |
|
console.log("\n═══ Connect/disconnect cycling: rapidly open+close connections while one is active ═══"); |
|
|
|
const main = await connectCDP(cdpWsUrl, "main"); |
|
let mainDead = false; |
|
main.browser.on("disconnected", () => { |
|
mainDead = true; |
|
console.log(" 🔴 Main connection DISCONNECTED"); |
|
}); |
|
|
|
const duration = 20_000; |
|
const start = Date.now(); |
|
let cycles = 0; |
|
|
|
// Main connection does screenshots |
|
const mainLoop = (async () => { |
|
let ok = 0, fail = 0; |
|
while (Date.now() - start < duration && !mainDead) { |
|
try { |
|
await main.page.screenshot({ type: "jpeg", quality: 50, timeout: 3000 }); |
|
ok++; |
|
} catch (err) { |
|
fail++; |
|
if (err.message.includes("closed") || err.message.includes("crashed")) { |
|
return { ok, fail, dead: true }; |
|
} |
|
} |
|
await new Promise((r) => setTimeout(r, 500)); |
|
} |
|
return { ok, fail, dead: mainDead }; |
|
})(); |
|
|
|
// Rapid connect/disconnect cycling |
|
const cycleLoop = (async () => { |
|
while (Date.now() - start < duration && !mainDead) { |
|
try { |
|
const temp = await chromium.connectOverCDP(cdpWsUrl); |
|
cycles++; |
|
// Immediately fire a command |
|
const ctx = temp.contexts()[0]; |
|
const page = ctx?.pages()[0]; |
|
if (page) { |
|
await page.evaluate(() => document.title).catch(() => {}); |
|
} |
|
// Disconnect immediately |
|
await temp.close(); |
|
} catch (err) { |
|
const msg = err.message.split("\n")[0].slice(0, 80); |
|
if (cycles <= 3 || cycles % 20 === 0) { |
|
console.log(` [cycle] error at cycle ${cycles}: ${msg}`); |
|
} |
|
} |
|
} |
|
})(); |
|
|
|
const [mainResult] = await Promise.all([mainLoop, cycleLoop]); |
|
console.log(` [main] ${mainResult.ok} ok, ${mainResult.fail} fail, dead=${mainResult.dead}`); |
|
console.log(` [cycles] ${cycles} connect/disconnect cycles completed`); |
|
|
|
const dropped = mainResult.dead; |
|
try { await main.browser.close(); } catch {} |
|
return dropped; |
|
} |
|
|
|
// ── Scenario 5: Command flood — fire hundreds of CDP commands without waiting ── |
|
async function scenarioCommandFlood(cdpWsUrl) { |
|
console.log("\n═══ Command flood: fire 100 parallel CDP commands on one connection ═══"); |
|
|
|
const conn = await connectCDP(cdpWsUrl, "flood"); |
|
let dead = false; |
|
conn.browser.on("disconnected", () => { |
|
dead = true; |
|
console.log(" 🔴 Connection DISCONNECTED"); |
|
}); |
|
|
|
for (let batch = 0; batch < 5 && !dead; batch++) { |
|
console.log(` Batch ${batch}: firing 100 concurrent commands...`); |
|
const promises = []; |
|
for (let i = 0; i < 100; i++) { |
|
switch (i % 4) { |
|
case 0: |
|
promises.push(conn.page.screenshot({ type: "jpeg", quality: 30, timeout: 10000 }).catch((e) => ({ err: e }))); |
|
break; |
|
case 1: |
|
promises.push(conn.page.evaluate(() => document.querySelectorAll("*").length).catch((e) => ({ err: e }))); |
|
break; |
|
case 2: |
|
promises.push(conn.page.evaluate(() => { |
|
void document.body.innerHTML.length; |
|
return Date.now(); |
|
}).catch((e) => ({ err: e }))); |
|
break; |
|
case 3: |
|
promises.push(conn.page.title().catch((e) => ({ err: e }))); |
|
break; |
|
} |
|
} |
|
|
|
const results = await Promise.all(promises); |
|
const ok = results.filter((r) => !r?.err).length; |
|
const fails = results.filter((r) => r?.err).length; |
|
const deadErrors = results.filter((r) => r?.err?.message?.includes("closed") || r?.err?.message?.includes("crashed")).length; |
|
console.log(` Batch ${batch}: ${ok} ok, ${fails} fail (${deadErrors} dead-errors)`); |
|
|
|
// Log sample errors |
|
const errSamples = results.filter((r) => r?.err).slice(0, 3); |
|
for (const e of errSamples) { |
|
console.log(` sample error: ${e.err.message.split("\n")[0].slice(0, 100)}`); |
|
} |
|
|
|
if (deadErrors > 0 || dead) { |
|
console.log(" Connection died during flood"); |
|
try { await conn.browser.close(); } catch {} |
|
return true; |
|
} |
|
|
|
await new Promise((r) => setTimeout(r, 1000)); |
|
} |
|
|
|
const dropped = dead; |
|
try { await conn.browser.close(); } catch {} |
|
return dropped; |
|
} |
|
|
|
// ── Scenario 6: Evaluate + navigate race ── |
|
async function scenarioEvalNavigateRace(cdpWsUrl) { |
|
console.log("\n═══ Evaluate + navigate race: start evaluate then immediately navigate ═══"); |
|
|
|
const conn = await connectCDP(cdpWsUrl, "race"); |
|
let dead = false; |
|
conn.browser.on("disconnected", () => { |
|
dead = true; |
|
console.log(" 🔴 Connection DISCONNECTED"); |
|
}); |
|
|
|
for (let round = 0; round < 10 && !dead; round++) { |
|
console.log(` Round ${round}:`); |
|
|
|
// Fire a long evaluate and immediately navigate — this creates a race |
|
const evalPromise = conn.page |
|
.evaluate(() => { |
|
const end = Date.now() + 3000; |
|
while (Date.now() < end) Math.random(); |
|
return "eval-done"; |
|
}) |
|
.catch((e) => ({ err: e.message.split("\n")[0].slice(0, 100) })); |
|
|
|
// Immediately try to navigate (should race with the evaluate) |
|
const navPromise = conn.page |
|
.goto("about:blank", { timeout: 5000 }) |
|
.catch((e) => ({ err: e.message.split("\n")[0].slice(0, 100) })); |
|
|
|
// And fire a screenshot too |
|
const ssPromise = conn.page |
|
.screenshot({ type: "jpeg", quality: 50, timeout: 5000 }) |
|
.catch((e) => ({ err: e.message.split("\n")[0].slice(0, 100) })); |
|
|
|
const [evalR, navR, ssR] = await Promise.all([evalPromise, navPromise, ssPromise]); |
|
console.log(` eval: ${evalR?.err || "ok"}`); |
|
console.log(` nav: ${navR?.err || "ok"}`); |
|
console.log(` ss: ${ssR?.err || "ok"}`); |
|
|
|
if (dead) break; |
|
|
|
// Navigate back |
|
try { |
|
await conn.page.goto(TARGET_URL, { waitUntil: "domcontentloaded", timeout: 10000 }); |
|
} catch (e) { |
|
console.log(` re-nav failed: ${e.message.split("\n")[0].slice(0, 80)}`); |
|
} |
|
} |
|
|
|
const droppedR = dead; |
|
try { await conn.browser.close(); } catch {} |
|
return droppedR; |
|
} |
|
|
|
// ── Scenario 7: Two connections, cross-fire commands that invalidate each other ── |
|
async function scenarioCrossfire(cdpWsUrl) { |
|
console.log("\n═══ Crossfire: 2 connections, both navigate + evaluate + screenshot simultaneously ═══"); |
|
|
|
const c1 = await connectCDP(cdpWsUrl, "A"); |
|
const c2 = await connectCDP(cdpWsUrl, "B"); |
|
const deadSet = new Set(); |
|
|
|
c1.browser.on("disconnected", () => { deadSet.add("A"); console.log(" 🔴 Connection A DISCONNECTED"); }); |
|
c2.browser.on("disconnected", () => { deadSet.add("B"); console.log(" 🔴 Connection B DISCONNECTED"); }); |
|
|
|
console.log(" 2 connections established, both firing conflicting commands"); |
|
|
|
const duration = 30_000; |
|
const start = Date.now(); |
|
|
|
async function worker(c) { |
|
let ok = 0, fail = 0; |
|
while (Date.now() - start < duration && !deadSet.has(c.id)) { |
|
const batch = [ |
|
c.page.screenshot({ type: "jpeg", quality: 50, timeout: 3000 }).catch((e) => ({ err: e })), |
|
c.page.evaluate(() => { |
|
// Force layout reflow |
|
const all = document.querySelectorAll("*"); |
|
for (const el of all) void el.getBoundingClientRect(); |
|
return all.length; |
|
}).catch((e) => ({ err: e })), |
|
c.page.evaluate(() => { |
|
// Modify DOM |
|
const d = document.createElement("div"); |
|
d.id = `test-${Date.now()}`; |
|
d.textContent = "x".repeat(10000); |
|
document.body.appendChild(d); |
|
return d.id; |
|
}).catch((e) => ({ err: e })), |
|
]; |
|
|
|
const results = await Promise.all(batch); |
|
const batchOk = results.filter((r) => !r?.err).length; |
|
const batchFail = results.filter((r) => r?.err).length; |
|
ok += batchOk; |
|
fail += batchFail; |
|
|
|
const deadErr = results.find((r) => r?.err?.message?.includes("closed") || r?.err?.message?.includes("crashed")); |
|
if (deadErr) { |
|
return { id: c.id, ok, fail, dead: true }; |
|
} |
|
} |
|
return { id: c.id, ok, fail, dead: deadSet.has(c.id) }; |
|
} |
|
|
|
const [r1, r2] = await Promise.all([worker(c1), worker(c2)]); |
|
console.log(` [A] ${r1.ok} ok, ${r1.fail} fail, dead=${r1.dead}`); |
|
console.log(` [B] ${r2.ok} ok, ${r2.fail} fail, dead=${r2.dead}`); |
|
|
|
const droppedCF = deadSet.size > 0; |
|
try { await c1.browser.close(); } catch {} |
|
try { await c2.browser.close(); } catch {} |
|
return droppedCF; |
|
} |
|
|
|
// ── Main ── |
|
async function main() { |
|
console.log("Creating Kernel browser session..."); |
|
const kernelBrowser = await kernel.browsers.create({ timeout_seconds: 300 }); |
|
const sessionId = kernelBrowser.session_id; |
|
const cdpWsUrl = kernelBrowser.cdp_ws_url; |
|
console.log(`Session: ${sessionId}`); |
|
console.log(`Target URL: ${TARGET_URL}\n`); |
|
|
|
await vmStats(sessionId); |
|
|
|
// Navigate on one connection first to get page loaded |
|
const setup = await connectCDP(cdpWsUrl, "setup"); |
|
await setup.page.goto(TARGET_URL, { waitUntil: "domcontentloaded", timeout: 30000 }); |
|
console.log("Page loaded via setup connection."); |
|
await setup.browser.close(); |
|
|
|
const scenarios = { |
|
"parallel-ss": () => scenarioParallelScreenshots(cdpWsUrl, 5), |
|
"parallel-ss-10": () => scenarioParallelScreenshots(cdpWsUrl, 10), |
|
mixed: () => scenarioMixedCommands(cdpWsUrl, 4), |
|
"blocker-flood": () => scenarioBlockerPlusFlood(cdpWsUrl), |
|
"connect-cycle": () => scenarioConnectDisconnectCycle(cdpWsUrl), |
|
"cmd-flood": () => scenarioCommandFlood(cdpWsUrl), |
|
"eval-nav-race": () => scenarioEvalNavigateRace(cdpWsUrl), |
|
crossfire: () => scenarioCrossfire(cdpWsUrl), |
|
}; |
|
|
|
const toRun = SCENARIO === "all" ? Object.entries(scenarios) : [[SCENARIO, scenarios[SCENARIO]]]; |
|
|
|
if (SCENARIO !== "all" && !scenarios[SCENARIO]) { |
|
console.error(`Unknown scenario: ${SCENARIO}. Available: ${Object.keys(scenarios).join(", ")}`); |
|
process.exit(1); |
|
} |
|
|
|
let anyDied = false; |
|
for (const [name, fn] of toRun) { |
|
console.log(`\n─── VM state before ${name} ───`); |
|
await vmStats(sessionId); |
|
|
|
const dropped = await fn(); |
|
|
|
console.log(`\n─── VM state after ${name} ───`); |
|
await vmStats(sessionId); |
|
|
|
if (dropped) { |
|
console.log(`\n🔴 CDP CONNECTION DROPPED during: ${name}`); |
|
anyDied = true; |
|
// Don't break — try to keep going if possible |
|
} else { |
|
console.log(` ✓ ${name} survived`); |
|
} |
|
} |
|
|
|
console.log(`\nCleaning up session ${sessionId}...`); |
|
await kernel.browsers.deleteByID(sessionId); |
|
console.log(anyDied ? "\n⚠️ At least one scenario caused a connection drop." : "\nAll scenarios survived."); |
|
} |
|
|
|
main().catch(console.error); |