Compare commits
11 commits
a24d5415eb
...
5cba8bdcd8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cba8bdcd8 | ||
|
|
a7361a045c | ||
|
|
0c0c6479bf | ||
|
|
3f1ebcae62 | ||
|
|
3babddc187 | ||
|
|
eaae60adef | ||
|
|
cc634206e2 | ||
|
|
c0b7449430 | ||
|
|
3a6f124379 | ||
|
|
6321660f94 | ||
|
|
769ee0ee30 |
38 changed files with 9653 additions and 8940 deletions
|
|
@ -722,6 +722,66 @@ fn registry_benchmarks(c: &mut Criterion) {
|
|||
group.finish();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Allocation decomposition — where does send_to time go?
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn allocation_benchmarks(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("allocation");
|
||||
|
||||
// D1 — Bare Box allocation + type erasure (no runtime, no channels)
|
||||
for size in [0usize, 64, 256, 1024, 4096] {
|
||||
let label = if size == 0 { "zero".to_string() } else { format!("{size}B") };
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("box_alloc_erase", &label),
|
||||
&size,
|
||||
|b, &size| {
|
||||
b.iter(|| {
|
||||
let msg: Box<dyn std::any::Any + Send> = if size == 0 {
|
||||
Box::new(NoopMessage)
|
||||
} else {
|
||||
Box::new(SizedMessage { _payload: vec![0u8; size] })
|
||||
};
|
||||
std::hint::black_box(msg);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// D2 — Full send_to for comparison (same sizes as D1)
|
||||
for size in [0usize, 64, 256, 1024, 4096] {
|
||||
let label = if size == 0 { "zero".to_string() } else { format!("{size}B") };
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("full_send_to", &label),
|
||||
&size,
|
||||
|b, &size| {
|
||||
b.iter_batched(
|
||||
|| {
|
||||
let rt = Runtime::new(make_config(100, 100_000));
|
||||
let addr = if size == 0 {
|
||||
rt.spawn(NoopActor).unwrap()
|
||||
} else {
|
||||
rt.spawn(SizedSinkActor).unwrap()
|
||||
};
|
||||
rt.tick();
|
||||
(rt, addr, size)
|
||||
},
|
||||
|(rt, addr, sz)| {
|
||||
if sz == 0 {
|
||||
rt.send_to(addr, NoopMessage).unwrap();
|
||||
} else {
|
||||
rt.send_to(addr, SizedMessage { _payload: vec![0u8; sz] }).unwrap();
|
||||
}
|
||||
},
|
||||
BatchSize::SmallInput,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(
|
||||
benches,
|
||||
latency_benchmarks,
|
||||
|
|
@ -731,5 +791,6 @@ criterion_group!(
|
|||
contention_benchmarks,
|
||||
placement_benchmarks,
|
||||
registry_benchmarks,
|
||||
allocation_benchmarks,
|
||||
);
|
||||
criterion_main!(benches);
|
||||
|
|
|
|||
739
crates/runtime-dashboard/src/actors.html
Normal file
739
crates/runtime-dashboard/src/actors.html
Normal file
|
|
@ -0,0 +1,739 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Swactor Runtime – Actors</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Menlo', 'Consolas', 'Monaco', monospace; background: #0f1117; color: #e0e0e0; font-size: 13px; }
|
||||
|
||||
.header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 12px 20px; background: #161822; border-bottom: 1px solid #2a2d3e;
|
||||
}
|
||||
.header-left { display: flex; align-items: center; }
|
||||
.header h1 { font-size: 16px; font-weight: 600; color: #fff; }
|
||||
.status-dot {
|
||||
width: 10px; height: 10px; border-radius: 50%; background: #4caf50;
|
||||
display: inline-block; margin-left: 8px; vertical-align: middle;
|
||||
}
|
||||
.status-dot.disconnected { background: #f44336; }
|
||||
.status-dot.done { background: #ff9800; }
|
||||
.status-dot.replaying { background: #2196f3; animation: pulse 1.5s infinite; }
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
.replay-badge {
|
||||
display: none; background: #2196f3; color: #fff; font-size: 10px; font-weight: 700;
|
||||
padding: 2px 8px; border-radius: 3px; margin-left: 10px; letter-spacing: 1px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.replay-badge.visible { display: inline-block; }
|
||||
|
||||
.nav-links { display: flex; gap: 4px; margin-left: 20px; }
|
||||
.nav-link {
|
||||
color: #888; text-decoration: none; font-size: 12px;
|
||||
padding: 4px 10px; border-radius: 3px; transition: color 0.2s;
|
||||
}
|
||||
.nav-link:hover { color: #e0e0e0; }
|
||||
.nav-link.active { color: #fff; background: #2a2d3e; }
|
||||
|
||||
.header-right { display: flex; align-items: center; gap: 12px; }
|
||||
|
||||
.progress-bar-wrap {
|
||||
display: none; width: 100%; height: 3px; background: #2a2d3e;
|
||||
}
|
||||
.progress-bar-wrap.visible { display: block; }
|
||||
.progress-fill {
|
||||
height: 100%; width: 0%; background: #2196f3; transition: width 0.3s;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px; padding: 12px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: #161822; border: 1px solid #2a2d3e; border-radius: 6px;
|
||||
padding: 14px; overflow: hidden;
|
||||
}
|
||||
.panel h2 { font-size: 12px; color: #888; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 10px; }
|
||||
|
||||
.full-width { grid-column: 1 / -1; }
|
||||
|
||||
.stats-cards {
|
||||
display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px;
|
||||
}
|
||||
.stat-card {
|
||||
background: #1c1f2e; border-radius: 4px; padding: 10px; text-align: center;
|
||||
}
|
||||
.stat-card .value { font-size: 22px; font-weight: 700; color: #fff; }
|
||||
.stat-card .label { font-size: 10px; color: #888; text-transform: uppercase; margin-top: 2px; }
|
||||
|
||||
canvas { width: 100%; height: 200px; }
|
||||
|
||||
.search-wrap { margin-bottom: 10px; display: flex; align-items: center; gap: 12px; }
|
||||
.search-input {
|
||||
background: #1c1f2e; border: 1px solid #2a2d3e; color: #e0e0e0;
|
||||
padding: 6px 10px; border-radius: 4px; font-family: inherit;
|
||||
font-size: 12px; width: 300px; outline: none;
|
||||
}
|
||||
.search-input:focus { border-color: #6366f1; }
|
||||
.search-info { color: #555; font-size: 11px; }
|
||||
|
||||
.actor-list-wrap { max-height: 500px; overflow-y: auto; }
|
||||
.actor-list-wrap table { width: 100%; border-collapse: collapse; }
|
||||
.actor-list-wrap th, .actor-list-wrap td {
|
||||
padding: 4px 8px; text-align: left; border-bottom: 1px solid #2a2d3e; font-size: 12px;
|
||||
}
|
||||
.actor-list-wrap th {
|
||||
color: #888; font-weight: 500; position: sticky; top: 0; background: #161822;
|
||||
}
|
||||
.sortable { cursor: pointer; user-select: none; }
|
||||
.sortable:hover { color: #e0e0e0; }
|
||||
.sort-arrow { font-size: 10px; margin-left: 4px; color: #6366f1; }
|
||||
|
||||
.depth-bar {
|
||||
height: 8px; border-radius: 2px; max-width: 120px; min-width: 2px;
|
||||
}
|
||||
|
||||
.msg-type { color: #4caf50; }
|
||||
.msg-type.none { color: #555; font-style: italic; }
|
||||
|
||||
tr.focused { background: #1c1f2e; }
|
||||
tr.clickable { cursor: pointer; }
|
||||
tr.clickable:hover { background: #1a1d2c; }
|
||||
|
||||
.detail-panel { display: none; }
|
||||
.detail-panel.visible { display: block; }
|
||||
.detail-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||||
.detail-close {
|
||||
background: none; border: 1px solid #2a2d3e; color: #888; border-radius: 3px;
|
||||
padding: 2px 8px; cursor: pointer; font-family: inherit; font-size: 11px;
|
||||
}
|
||||
.detail-close:hover { color: #e0e0e0; border-color: #555; }
|
||||
.detail-grid {
|
||||
display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 12px;
|
||||
}
|
||||
.detail-item { background: #1c1f2e; border-radius: 4px; padding: 8px 10px; }
|
||||
.detail-item .d-label { font-size: 10px; color: #888; text-transform: uppercase; }
|
||||
.detail-item .d-value { font-size: 14px; font-weight: 700; color: #fff; margin-top: 2px; word-break: break-all; }
|
||||
.poisoned-badge {
|
||||
background: #f44336; color: #fff; font-size: 10px; font-weight: 700;
|
||||
padding: 2px 6px; border-radius: 3px; letter-spacing: 0.5px;
|
||||
}
|
||||
.healthy-badge { color: #4caf50; font-size: 12px; }
|
||||
canvas.sparkline { width: 100%; height: 60px; }
|
||||
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: #0f1117; }
|
||||
::-webkit-scrollbar-thumb { background: #2a2d3e; border-radius: 3px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<h1>
|
||||
Swactor Runtime Dashboard
|
||||
<span id="statusDot" class="status-dot"></span>
|
||||
<span id="replayBadge" class="replay-badge">REPLAY</span>
|
||||
</h1>
|
||||
<nav class="nav-links">
|
||||
<a href="/" class="nav-link">Overview</a>
|
||||
<a href="/actors" class="nav-link active">Actors</a>
|
||||
<a href="/distribution" class="nav-link">Distribution</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<span id="replaySpeed" style="color:#2196f3;font-size:12px;display:none;"></span>
|
||||
<span id="replayPct" style="color:#888;font-size:12px;display:none;"></span>
|
||||
<span id="uptimeLabel" style="color:#888;font-size:12px;"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="progressBarWrap" class="progress-bar-wrap">
|
||||
<div id="progressFill" class="progress-fill"></div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<!-- Stat cards -->
|
||||
<div class="panel full-width">
|
||||
<h2>Actor Stats</h2>
|
||||
<div class="stats-cards">
|
||||
<div class="stat-card"><div class="value" id="statTotal">0</div><div class="label">Total Actors</div></div>
|
||||
<div class="stat-card"><div class="value" id="statAvgMbox">0</div><div class="label">Avg Mailbox</div></div>
|
||||
<div class="stat-card"><div class="value" id="statMaxMbox">0</div><div class="label">Max Mailbox</div></div>
|
||||
<div class="stat-card"><div class="value" id="statActiveWorkers">0</div><div class="label">Active Workers</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mailbox depth distribution -->
|
||||
<div class="panel">
|
||||
<h2>Mailbox Depth Distribution</h2>
|
||||
<canvas id="depthChart"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- Actors per worker -->
|
||||
<div class="panel">
|
||||
<h2>Actors per Worker</h2>
|
||||
<canvas id="workerChart"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- Actor table -->
|
||||
<div class="panel full-width">
|
||||
<h2>All Actors <span id="actorCount" style="color:#555;font-weight:400;"></span></h2>
|
||||
<div class="search-wrap">
|
||||
<input type="text" id="actorSearch" class="search-input" placeholder="Filter by address or worker..." />
|
||||
<span id="searchInfo" class="search-info"></span>
|
||||
</div>
|
||||
<div class="actor-list-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable" data-sort="address">Address <span id="sortArrowAddress" class="sort-arrow"></span></th>
|
||||
<th class="sortable" data-sort="worker">Worker <span id="sortArrowWorker" class="sort-arrow"></span></th>
|
||||
<th class="sortable" data-sort="mailbox">Mailbox <span id="sortArrowMailbox" class="sort-arrow"></span></th>
|
||||
<th class="sortable" data-sort="msgs">Msgs <span id="sortArrowMsgs" class="sort-arrow"></span></th>
|
||||
<th>Last Msg</th>
|
||||
<th>Depth</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="actorTableBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actor detail panel -->
|
||||
<div id="detailPanel" class="panel full-width detail-panel">
|
||||
<div class="detail-header">
|
||||
<h2>Actor Detail <span id="detailAddr" style="color:#aaa;font-weight:400;"></span></h2>
|
||||
<button class="detail-close" id="detailClose">Close</button>
|
||||
</div>
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item"><div class="d-label">Full Address</div><div class="d-value" id="detailFullAddr" style="font-size:11px;"></div></div>
|
||||
<div class="detail-item"><div class="d-label">Worker</div><div class="d-value" id="detailWorker"></div></div>
|
||||
<div class="detail-item"><div class="d-label">Mailbox Depth</div><div class="d-value" id="detailMailbox"></div></div>
|
||||
<div class="detail-item"><div class="d-label">Messages Processed</div><div class="d-value" id="detailMsgCount"></div></div>
|
||||
<div class="detail-item"><div class="d-label">Last Message Type</div><div class="d-value" id="detailLastMsg"></div></div>
|
||||
<div class="detail-item"><div class="d-label">Status</div><div class="d-value" id="detailStatus"></div></div>
|
||||
</div>
|
||||
<h2>Mailbox Depth History</h2>
|
||||
<canvas id="sparkline" class="sparkline"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var DASHBOARD_MODE = '__DASHBOARD_MODE__';
|
||||
var isReplay = (DASHBOARD_MODE === 'replay');
|
||||
var lastUptimeMs = null;
|
||||
var lastStatsTime = null;
|
||||
|
||||
var dot = document.getElementById('statusDot');
|
||||
var uptimeLabel = document.getElementById('uptimeLabel');
|
||||
var replayBadge = document.getElementById('replayBadge');
|
||||
var replaySpeed = document.getElementById('replaySpeed');
|
||||
var replayPct = document.getElementById('replayPct');
|
||||
var progressBarWrap = document.getElementById('progressBarWrap');
|
||||
var progressFill = document.getElementById('progressFill');
|
||||
|
||||
if (isReplay) {
|
||||
replayBadge.className = 'replay-badge visible';
|
||||
progressBarWrap.className = 'progress-bar-wrap visible';
|
||||
dot.className = 'status-dot replaying';
|
||||
uptimeLabel.style.display = 'none';
|
||||
}
|
||||
|
||||
function updateUptime() {
|
||||
if (isReplay) return;
|
||||
var up = lastUptimeMs;
|
||||
if (up !== null && lastStatsTime !== null) {
|
||||
up += (Date.now() - lastStatsTime);
|
||||
}
|
||||
if (up === null) { uptimeLabel.textContent = ''; return; }
|
||||
var s = Math.floor(up / 1000);
|
||||
var d = Math.floor(s / 86400);
|
||||
var h = Math.floor((s % 86400) / 3600);
|
||||
var m = Math.floor((s % 3600) / 60);
|
||||
var sec = s % 60;
|
||||
var parts = [];
|
||||
if (d > 0) parts.push(d + 'd');
|
||||
if (h > 0 || d > 0) parts.push(h + 'h');
|
||||
parts.push(m + 'm');
|
||||
parts.push(sec + 's');
|
||||
uptimeLabel.textContent = parts.join(' ');
|
||||
}
|
||||
setInterval(updateUptime, 1000);
|
||||
|
||||
// ── State ──────────────────────────────────────────────
|
||||
var currentActors = [];
|
||||
var sortCol = 'worker';
|
||||
var sortAsc = true;
|
||||
var searchTimer = null;
|
||||
var focusedAddrHex = null;
|
||||
var depthHistory = {};
|
||||
var HISTORY_MAX = 120;
|
||||
|
||||
var colors = ['#4caf50','#2196f3','#ff9800','#f44336','#9c27b0','#00bcd4','#ffeb3b','#e91e63'];
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────
|
||||
function addrToHex(addr) {
|
||||
var bytes = Array.isArray(addr) ? addr : Object.values(addr);
|
||||
var hex = '';
|
||||
for (var j = 0; j < Math.min(8, bytes.length); j++) {
|
||||
hex += ('0' + bytes[j].toString(16)).slice(-2);
|
||||
}
|
||||
return hex + '\u2026';
|
||||
}
|
||||
|
||||
function addrToFullHex(addr) {
|
||||
var bytes = Array.isArray(addr) ? addr : Object.values(addr);
|
||||
var hex = '';
|
||||
for (var j = 0; j < bytes.length; j++) {
|
||||
hex += ('0' + bytes[j].toString(16)).slice(-2);
|
||||
}
|
||||
return hex;
|
||||
}
|
||||
|
||||
function shortTypeName(full) {
|
||||
if (!full) return '';
|
||||
var parts = full.split('::');
|
||||
return parts[parts.length - 1];
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
if (!s) return '';
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
function depthColor(d) {
|
||||
if (d === 0) return '#4caf50';
|
||||
if (d <= 2) return '#8bc34a';
|
||||
if (d <= 5) return '#cddc39';
|
||||
if (d <= 10) return '#ff9800';
|
||||
if (d <= 20) return '#ff5722';
|
||||
return '#f44336';
|
||||
}
|
||||
|
||||
// ── Stat cards ─────────────────────────────────────────
|
||||
function updateStatCards(actors) {
|
||||
var total = actors.length;
|
||||
var sum = 0, max = 0;
|
||||
var workerSet = {};
|
||||
for (var i = 0; i < actors.length; i++) {
|
||||
sum += actors[i].mailbox_depth;
|
||||
if (actors[i].mailbox_depth > max) max = actors[i].mailbox_depth;
|
||||
workerSet[actors[i].worker_id] = true;
|
||||
}
|
||||
var avg = total > 0 ? (sum / total).toFixed(1) : '0';
|
||||
document.getElementById('statTotal').textContent = total;
|
||||
document.getElementById('statAvgMbox').textContent = avg;
|
||||
document.getElementById('statMaxMbox').textContent = max;
|
||||
document.getElementById('statActiveWorkers').textContent = Object.keys(workerSet).length;
|
||||
}
|
||||
|
||||
// ── Mailbox depth distribution chart ───────────────────
|
||||
var depthCanvas = document.getElementById('depthChart');
|
||||
var depthCtx = depthCanvas.getContext('2d');
|
||||
|
||||
var buckets = [
|
||||
{label: '0', min: 0, max: 0},
|
||||
{label: '1-2', min: 1, max: 2},
|
||||
{label: '3-5', min: 3, max: 5},
|
||||
{label: '6-10', min: 6, max: 10},
|
||||
{label: '11-20', min: 11, max: 20},
|
||||
{label: '21-50', min: 21, max: 50},
|
||||
{label: '50+', min: 51, max: Infinity}
|
||||
];
|
||||
|
||||
var bucketColors = ['#4caf50','#8bc34a','#cddc39','#ff9800','#ff5722','#f44336','#d32f2f'];
|
||||
|
||||
function drawDepthChart(actors) {
|
||||
var dpr = window.devicePixelRatio || 1;
|
||||
var rect = depthCanvas.getBoundingClientRect();
|
||||
depthCanvas.width = rect.width * dpr;
|
||||
depthCanvas.height = rect.height * dpr;
|
||||
depthCtx.scale(dpr, dpr);
|
||||
var W = rect.width, H = rect.height;
|
||||
depthCtx.clearRect(0, 0, W, H);
|
||||
|
||||
var counts = buckets.map(function() { return 0; });
|
||||
for (var i = 0; i < actors.length; i++) {
|
||||
var d = actors[i].mailbox_depth;
|
||||
for (var b = 0; b < buckets.length; b++) {
|
||||
if (d >= buckets[b].min && d <= buckets[b].max) {
|
||||
counts[b]++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var maxCount = Math.max(1, Math.max.apply(null, counts));
|
||||
var barW = Math.max(12, Math.floor((W - 40) / buckets.length) - 8);
|
||||
var chartH = H - 35;
|
||||
|
||||
for (var i = 0; i < buckets.length; i++) {
|
||||
var x = 20 + i * (barW + 8);
|
||||
var h = (counts[i] / maxCount) * (chartH - 10);
|
||||
depthCtx.fillStyle = bucketColors[i];
|
||||
depthCtx.globalAlpha = 0.85;
|
||||
depthCtx.fillRect(x, chartH - h, barW, h);
|
||||
|
||||
if (counts[i] > 0) {
|
||||
depthCtx.globalAlpha = 1;
|
||||
depthCtx.fillStyle = '#e0e0e0';
|
||||
depthCtx.font = '10px monospace';
|
||||
depthCtx.textAlign = 'center';
|
||||
depthCtx.fillText(counts[i], x + barW / 2, chartH - h - 4);
|
||||
}
|
||||
|
||||
depthCtx.globalAlpha = 1;
|
||||
depthCtx.fillStyle = '#888';
|
||||
depthCtx.font = '10px monospace';
|
||||
depthCtx.textAlign = 'center';
|
||||
depthCtx.fillText(buckets[i].label, x + barW / 2, H - 4);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Actors per worker chart ────────────────────────────
|
||||
var workerCanvas = document.getElementById('workerChart');
|
||||
var workerCtx = workerCanvas.getContext('2d');
|
||||
|
||||
function drawWorkerChart(actors) {
|
||||
var dpr = window.devicePixelRatio || 1;
|
||||
var rect = workerCanvas.getBoundingClientRect();
|
||||
workerCanvas.width = rect.width * dpr;
|
||||
workerCanvas.height = rect.height * dpr;
|
||||
workerCtx.scale(dpr, dpr);
|
||||
var W = rect.width, H = rect.height;
|
||||
workerCtx.clearRect(0, 0, W, H);
|
||||
|
||||
var workerCounts = {};
|
||||
for (var i = 0; i < actors.length; i++) {
|
||||
var wid = actors[i].worker_id;
|
||||
workerCounts[wid] = (workerCounts[wid] || 0) + 1;
|
||||
}
|
||||
var entries = Object.keys(workerCounts).sort(function(a, b) { return +a - +b; })
|
||||
.map(function(wid) { return {id: +wid, count: workerCounts[wid]}; });
|
||||
|
||||
if (entries.length === 0) return;
|
||||
|
||||
var maxCount = Math.max(1, Math.max.apply(null, entries.map(function(e) { return e.count; })));
|
||||
var barW = Math.max(12, Math.floor((W - 40) / entries.length) - 8);
|
||||
var chartH = H - 35;
|
||||
|
||||
for (var i = 0; i < entries.length; i++) {
|
||||
var x = 20 + i * (barW + 8);
|
||||
var h = (entries[i].count / maxCount) * (chartH - 10);
|
||||
workerCtx.fillStyle = colors[entries[i].id % colors.length];
|
||||
workerCtx.globalAlpha = 0.85;
|
||||
workerCtx.fillRect(x, chartH - h, barW, h);
|
||||
|
||||
workerCtx.globalAlpha = 1;
|
||||
workerCtx.fillStyle = '#e0e0e0';
|
||||
workerCtx.font = '10px monospace';
|
||||
workerCtx.textAlign = 'center';
|
||||
workerCtx.fillText(entries[i].count, x + barW / 2, chartH - h - 4);
|
||||
|
||||
workerCtx.fillStyle = '#888';
|
||||
workerCtx.fillText('W' + entries[i].id, x + barW / 2, H - 4);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Actor table ────────────────────────────────────────
|
||||
var MAX_TABLE_ROWS = 2000;
|
||||
|
||||
function renderActorTable() {
|
||||
var filter = document.getElementById('actorSearch').value.toLowerCase();
|
||||
var filtered = currentActors;
|
||||
if (filter) {
|
||||
filtered = currentActors.filter(function(a) {
|
||||
var hex = addrToHex(a.address).toLowerCase();
|
||||
return hex.indexOf(filter) >= 0 || ('w' + a.worker_id).indexOf(filter) >= 0;
|
||||
});
|
||||
}
|
||||
|
||||
// Sort
|
||||
filtered.sort(function(a, b) {
|
||||
var va, vb;
|
||||
if (sortCol === 'address') {
|
||||
va = addrToHex(a.address);
|
||||
vb = addrToHex(b.address);
|
||||
return sortAsc ? va.localeCompare(vb) : vb.localeCompare(va);
|
||||
} else if (sortCol === 'worker') {
|
||||
va = a.worker_id; vb = b.worker_id;
|
||||
} else if (sortCol === 'msgs') {
|
||||
va = a.messages_processed || 0; vb = b.messages_processed || 0;
|
||||
} else {
|
||||
va = a.mailbox_depth; vb = b.mailbox_depth;
|
||||
}
|
||||
return sortAsc ? va - vb : vb - va;
|
||||
});
|
||||
|
||||
// Update sort arrows
|
||||
['address', 'worker', 'mailbox', 'msgs'].forEach(function(col) {
|
||||
var key = col.charAt(0).toUpperCase() + col.slice(1);
|
||||
var el = document.getElementById('sortArrow' + key);
|
||||
if (col === sortCol) {
|
||||
el.textContent = sortAsc ? '\u25B2' : '\u25BC';
|
||||
} else {
|
||||
el.textContent = '';
|
||||
}
|
||||
});
|
||||
|
||||
var maxDepth = 1;
|
||||
for (var i = 0; i < currentActors.length; i++) {
|
||||
if (currentActors[i].mailbox_depth > maxDepth) maxDepth = currentActors[i].mailbox_depth;
|
||||
}
|
||||
|
||||
var tbody = document.getElementById('actorTableBody');
|
||||
tbody.innerHTML = '';
|
||||
var count = Math.min(filtered.length, MAX_TABLE_ROWS);
|
||||
for (var i = 0; i < count; i++) {
|
||||
var a = filtered[i];
|
||||
var hex = addrToHex(a.address);
|
||||
var fullHex = addrToFullHex(a.address);
|
||||
var pct = Math.round((a.mailbox_depth / maxDepth) * 120);
|
||||
var bc = depthColor(a.mailbox_depth);
|
||||
var hasMsg = !!a.last_msg_type;
|
||||
var msgShort = hasMsg ? shortTypeName(a.last_msg_type) : '\u2014';
|
||||
var msgClass = hasMsg ? 'msg-type' : 'msg-type none';
|
||||
var msgTitle = hasMsg ? ' title="' + escapeHtml(a.last_msg_type) + '"' : '';
|
||||
var tr = document.createElement('tr');
|
||||
tr.className = 'clickable' + (fullHex === focusedAddrHex ? ' focused' : '');
|
||||
if (a.poisoned) tr.style.opacity = '0.6';
|
||||
tr.setAttribute('data-addr', fullHex);
|
||||
tr.innerHTML =
|
||||
'<td style="color:#aaa;font-size:11px;">' + escapeHtml(hex) + (a.poisoned ? ' <span style="color:#f44336;font-size:9px;">DEAD</span>' : '') + '</td>' +
|
||||
'<td>W' + a.worker_id + '</td>' +
|
||||
'<td>' + a.mailbox_depth + '</td>' +
|
||||
'<td>' + (a.messages_processed || 0).toLocaleString() + '</td>' +
|
||||
'<td class="' + msgClass + '"' + msgTitle + '>' + escapeHtml(msgShort) + '</td>' +
|
||||
'<td><div class="depth-bar" style="width:' + pct + 'px;background:' + bc + ';"></div></td>';
|
||||
tr.addEventListener('click', (function(fh) { return function() { focusActor(fh); }; })(fullHex));
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
|
||||
if (filtered.length > MAX_TABLE_ROWS) {
|
||||
var tr2 = document.createElement('tr');
|
||||
tr2.innerHTML = '<td colspan="6" style="color:#555;">... and ' + (filtered.length - MAX_TABLE_ROWS) + ' more</td>';
|
||||
tbody.appendChild(tr2);
|
||||
}
|
||||
|
||||
var info = '(' + filtered.length + (filtered.length !== currentActors.length ? ' of ' + currentActors.length : '') + ')';
|
||||
document.getElementById('actorCount').textContent = info;
|
||||
}
|
||||
|
||||
// ── Focus / detail panel ───────────────────────────────
|
||||
function focusActor(fullHex) {
|
||||
focusedAddrHex = fullHex;
|
||||
document.getElementById('detailPanel').className = 'panel full-width detail-panel visible';
|
||||
updateDetailPanel();
|
||||
renderActorTable();
|
||||
}
|
||||
|
||||
function clearFocus() {
|
||||
focusedAddrHex = null;
|
||||
document.getElementById('detailPanel').className = 'panel full-width detail-panel';
|
||||
renderActorTable();
|
||||
}
|
||||
|
||||
document.getElementById('detailClose').addEventListener('click', clearFocus);
|
||||
|
||||
function updateDetailPanel() {
|
||||
if (!focusedAddrHex) return;
|
||||
var actor = null;
|
||||
for (var i = 0; i < currentActors.length; i++) {
|
||||
if (addrToFullHex(currentActors[i].address) === focusedAddrHex) {
|
||||
actor = currentActors[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!actor) {
|
||||
document.getElementById('detailAddr').textContent = focusedAddrHex.substring(0, 16) + '\u2026 (gone)';
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('detailAddr').textContent = addrToHex(actor.address);
|
||||
document.getElementById('detailFullAddr').textContent = focusedAddrHex;
|
||||
document.getElementById('detailWorker').textContent = 'W' + actor.worker_id;
|
||||
document.getElementById('detailMailbox').textContent = actor.mailbox_depth;
|
||||
document.getElementById('detailMsgCount').textContent = (actor.messages_processed || 0).toLocaleString();
|
||||
|
||||
var hasMsg = !!actor.last_msg_type;
|
||||
document.getElementById('detailLastMsg').innerHTML = hasMsg
|
||||
? '<span class="msg-type" title="' + escapeHtml(actor.last_msg_type) + '">' + escapeHtml(shortTypeName(actor.last_msg_type)) + '</span>'
|
||||
: '<span class="msg-type none">\u2014</span>';
|
||||
|
||||
var isPoisoned = !!actor.poisoned;
|
||||
document.getElementById('detailStatus').innerHTML = isPoisoned
|
||||
? '<span class="poisoned-badge">POISONED</span>'
|
||||
: '<span class="healthy-badge">Healthy</span>';
|
||||
|
||||
drawSparkline();
|
||||
}
|
||||
|
||||
// ── Sparkline ──────────────────────────────────────────
|
||||
function drawSparkline() {
|
||||
var canvas = document.getElementById('sparkline');
|
||||
var ctx = canvas.getContext('2d');
|
||||
var dpr = window.devicePixelRatio || 1;
|
||||
var rect = canvas.getBoundingClientRect();
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
var W = rect.width, H = rect.height;
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
|
||||
var hist = depthHistory[focusedAddrHex];
|
||||
if (!hist || hist.length < 2) {
|
||||
ctx.fillStyle = '#555';
|
||||
ctx.font = '11px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('Collecting data\u2026', W / 2, H / 2);
|
||||
return;
|
||||
}
|
||||
|
||||
var max = Math.max(1, Math.max.apply(null, hist));
|
||||
var padY = 6, padX = 4;
|
||||
var drawW = W - padX * 2;
|
||||
var drawH = H - padY * 2;
|
||||
|
||||
// Fill area
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padX, H - padY);
|
||||
for (var i = 0; i < hist.length; i++) {
|
||||
var x = padX + (i / (hist.length - 1)) * drawW;
|
||||
var y = (H - padY) - (hist[i] / max) * drawH;
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.lineTo(padX + drawW, H - padY);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = 'rgba(99, 102, 241, 0.15)';
|
||||
ctx.fill();
|
||||
|
||||
// Line
|
||||
ctx.beginPath();
|
||||
for (var i = 0; i < hist.length; i++) {
|
||||
var x = padX + (i / (hist.length - 1)) * drawW;
|
||||
var y = (H - padY) - (hist[i] / max) * drawH;
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.strokeStyle = '#6366f1';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
|
||||
// Label
|
||||
var last = hist[hist.length - 1];
|
||||
ctx.fillStyle = '#e0e0e0';
|
||||
ctx.font = '10px monospace';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText('depth: ' + last + ' max: ' + max, W - padX, padY + 8);
|
||||
}
|
||||
|
||||
// ── Sort click handlers ────────────────────────────────
|
||||
var sortHeaders = document.querySelectorAll('.sortable');
|
||||
for (var i = 0; i < sortHeaders.length; i++) {
|
||||
sortHeaders[i].addEventListener('click', function() {
|
||||
var col = this.getAttribute('data-sort');
|
||||
if (sortCol === col) { sortAsc = !sortAsc; }
|
||||
else { sortCol = col; sortAsc = true; }
|
||||
renderActorTable();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Search handler ─────────────────────────────────────
|
||||
document.getElementById('actorSearch').addEventListener('keyup', function() {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(renderActorTable, 150);
|
||||
});
|
||||
|
||||
// ── Status helpers ─────────────────────────────────────
|
||||
function setStatus(s) {
|
||||
if (s === 'done') {
|
||||
dot.className = 'status-dot done';
|
||||
if (isReplay) {
|
||||
replayPct.textContent = '100%';
|
||||
progressFill.style.width = '100%';
|
||||
}
|
||||
} else if (s === 'disconnected') {
|
||||
dot.className = 'status-dot disconnected';
|
||||
} else {
|
||||
dot.className = isReplay ? 'status-dot replaying' : 'status-dot';
|
||||
}
|
||||
}
|
||||
|
||||
// ── SSE connection ─────────────────────────────────────
|
||||
var es = new EventSource('/events');
|
||||
|
||||
es.addEventListener('stats', function(e) {
|
||||
try {
|
||||
var data = JSON.parse(e.data);
|
||||
if (typeof data.uptime_ms === 'number') {
|
||||
lastUptimeMs = data.uptime_ms;
|
||||
lastStatsTime = Date.now();
|
||||
}
|
||||
currentActors = data.actor_details || [];
|
||||
updateStatCards(currentActors);
|
||||
drawDepthChart(currentActors);
|
||||
drawWorkerChart(currentActors);
|
||||
|
||||
// Accumulate per-actor depth history
|
||||
var liveAddrs = {};
|
||||
for (var i = 0; i < currentActors.length; i++) {
|
||||
var fh = addrToFullHex(currentActors[i].address);
|
||||
liveAddrs[fh] = true;
|
||||
if (!depthHistory[fh]) depthHistory[fh] = [];
|
||||
depthHistory[fh].push(currentActors[i].mailbox_depth);
|
||||
if (depthHistory[fh].length > HISTORY_MAX) depthHistory[fh].shift();
|
||||
}
|
||||
// Clean up stale entries for removed actors
|
||||
for (var key in depthHistory) {
|
||||
if (!liveAddrs[key]) delete depthHistory[key];
|
||||
}
|
||||
|
||||
renderActorTable();
|
||||
if (focusedAddrHex) updateDetailPanel();
|
||||
} catch(err) { console.error('stats parse error', err); }
|
||||
});
|
||||
|
||||
es.addEventListener('replay_meta', function(e) {
|
||||
try {
|
||||
var meta = JSON.parse(e.data);
|
||||
replaySpeed.textContent = meta.speed + 'x';
|
||||
replaySpeed.style.display = 'inline';
|
||||
replayPct.style.display = 'inline';
|
||||
replayPct.textContent = '0%';
|
||||
} catch(err) { console.error('replay_meta parse error', err); }
|
||||
});
|
||||
|
||||
es.addEventListener('replay_progress', function(e) {
|
||||
try {
|
||||
var data = JSON.parse(e.data);
|
||||
var pct = Math.round(data.progress * 100);
|
||||
replayPct.textContent = pct + '%';
|
||||
progressFill.style.width = pct + '%';
|
||||
} catch(err) { console.error('replay_progress parse error', err); }
|
||||
});
|
||||
|
||||
es.addEventListener('done', function() {
|
||||
setStatus('done');
|
||||
es.close();
|
||||
});
|
||||
|
||||
es.onerror = function() {
|
||||
setStatus('disconnected');
|
||||
};
|
||||
|
||||
es.onopen = function() {
|
||||
setStatus('connected');
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,740 +1 @@
|
|||
pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Swactor Runtime – Actors</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Menlo', 'Consolas', 'Monaco', monospace; background: #0f1117; color: #e0e0e0; font-size: 13px; }
|
||||
|
||||
.header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 12px 20px; background: #161822; border-bottom: 1px solid #2a2d3e;
|
||||
}
|
||||
.header-left { display: flex; align-items: center; }
|
||||
.header h1 { font-size: 16px; font-weight: 600; color: #fff; }
|
||||
.status-dot {
|
||||
width: 10px; height: 10px; border-radius: 50%; background: #4caf50;
|
||||
display: inline-block; margin-left: 8px; vertical-align: middle;
|
||||
}
|
||||
.status-dot.disconnected { background: #f44336; }
|
||||
.status-dot.done { background: #ff9800; }
|
||||
.status-dot.replaying { background: #2196f3; animation: pulse 1.5s infinite; }
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
.replay-badge {
|
||||
display: none; background: #2196f3; color: #fff; font-size: 10px; font-weight: 700;
|
||||
padding: 2px 8px; border-radius: 3px; margin-left: 10px; letter-spacing: 1px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.replay-badge.visible { display: inline-block; }
|
||||
|
||||
.nav-links { display: flex; gap: 4px; margin-left: 20px; }
|
||||
.nav-link {
|
||||
color: #888; text-decoration: none; font-size: 12px;
|
||||
padding: 4px 10px; border-radius: 3px; transition: color 0.2s;
|
||||
}
|
||||
.nav-link:hover { color: #e0e0e0; }
|
||||
.nav-link.active { color: #fff; background: #2a2d3e; }
|
||||
|
||||
.header-right { display: flex; align-items: center; gap: 12px; }
|
||||
|
||||
.progress-bar-wrap {
|
||||
display: none; width: 100%; height: 3px; background: #2a2d3e;
|
||||
}
|
||||
.progress-bar-wrap.visible { display: block; }
|
||||
.progress-fill {
|
||||
height: 100%; width: 0%; background: #2196f3; transition: width 0.3s;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px; padding: 12px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: #161822; border: 1px solid #2a2d3e; border-radius: 6px;
|
||||
padding: 14px; overflow: hidden;
|
||||
}
|
||||
.panel h2 { font-size: 12px; color: #888; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 10px; }
|
||||
|
||||
.full-width { grid-column: 1 / -1; }
|
||||
|
||||
.stats-cards {
|
||||
display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px;
|
||||
}
|
||||
.stat-card {
|
||||
background: #1c1f2e; border-radius: 4px; padding: 10px; text-align: center;
|
||||
}
|
||||
.stat-card .value { font-size: 22px; font-weight: 700; color: #fff; }
|
||||
.stat-card .label { font-size: 10px; color: #888; text-transform: uppercase; margin-top: 2px; }
|
||||
|
||||
canvas { width: 100%; height: 200px; }
|
||||
|
||||
.search-wrap { margin-bottom: 10px; display: flex; align-items: center; gap: 12px; }
|
||||
.search-input {
|
||||
background: #1c1f2e; border: 1px solid #2a2d3e; color: #e0e0e0;
|
||||
padding: 6px 10px; border-radius: 4px; font-family: inherit;
|
||||
font-size: 12px; width: 300px; outline: none;
|
||||
}
|
||||
.search-input:focus { border-color: #6366f1; }
|
||||
.search-info { color: #555; font-size: 11px; }
|
||||
|
||||
.actor-list-wrap { max-height: 500px; overflow-y: auto; }
|
||||
.actor-list-wrap table { width: 100%; border-collapse: collapse; }
|
||||
.actor-list-wrap th, .actor-list-wrap td {
|
||||
padding: 4px 8px; text-align: left; border-bottom: 1px solid #2a2d3e; font-size: 12px;
|
||||
}
|
||||
.actor-list-wrap th {
|
||||
color: #888; font-weight: 500; position: sticky; top: 0; background: #161822;
|
||||
}
|
||||
.sortable { cursor: pointer; user-select: none; }
|
||||
.sortable:hover { color: #e0e0e0; }
|
||||
.sort-arrow { font-size: 10px; margin-left: 4px; color: #6366f1; }
|
||||
|
||||
.depth-bar {
|
||||
height: 8px; border-radius: 2px; max-width: 120px; min-width: 2px;
|
||||
}
|
||||
|
||||
.msg-type { color: #4caf50; }
|
||||
.msg-type.none { color: #555; font-style: italic; }
|
||||
|
||||
tr.focused { background: #1c1f2e; }
|
||||
tr.clickable { cursor: pointer; }
|
||||
tr.clickable:hover { background: #1a1d2c; }
|
||||
|
||||
.detail-panel { display: none; }
|
||||
.detail-panel.visible { display: block; }
|
||||
.detail-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||||
.detail-close {
|
||||
background: none; border: 1px solid #2a2d3e; color: #888; border-radius: 3px;
|
||||
padding: 2px 8px; cursor: pointer; font-family: inherit; font-size: 11px;
|
||||
}
|
||||
.detail-close:hover { color: #e0e0e0; border-color: #555; }
|
||||
.detail-grid {
|
||||
display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 12px;
|
||||
}
|
||||
.detail-item { background: #1c1f2e; border-radius: 4px; padding: 8px 10px; }
|
||||
.detail-item .d-label { font-size: 10px; color: #888; text-transform: uppercase; }
|
||||
.detail-item .d-value { font-size: 14px; font-weight: 700; color: #fff; margin-top: 2px; word-break: break-all; }
|
||||
.poisoned-badge {
|
||||
background: #f44336; color: #fff; font-size: 10px; font-weight: 700;
|
||||
padding: 2px 6px; border-radius: 3px; letter-spacing: 0.5px;
|
||||
}
|
||||
.healthy-badge { color: #4caf50; font-size: 12px; }
|
||||
canvas.sparkline { width: 100%; height: 60px; }
|
||||
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: #0f1117; }
|
||||
::-webkit-scrollbar-thumb { background: #2a2d3e; border-radius: 3px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<h1>
|
||||
Swactor Runtime Dashboard
|
||||
<span id="statusDot" class="status-dot"></span>
|
||||
<span id="replayBadge" class="replay-badge">REPLAY</span>
|
||||
</h1>
|
||||
<nav class="nav-links">
|
||||
<a href="/" class="nav-link">Overview</a>
|
||||
<a href="/actors" class="nav-link active">Actors</a>
|
||||
<a href="/distribution" class="nav-link">Distribution</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<span id="replaySpeed" style="color:#2196f3;font-size:12px;display:none;"></span>
|
||||
<span id="replayPct" style="color:#888;font-size:12px;display:none;"></span>
|
||||
<span id="uptimeLabel" style="color:#888;font-size:12px;"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="progressBarWrap" class="progress-bar-wrap">
|
||||
<div id="progressFill" class="progress-fill"></div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<!-- Stat cards -->
|
||||
<div class="panel full-width">
|
||||
<h2>Actor Stats</h2>
|
||||
<div class="stats-cards">
|
||||
<div class="stat-card"><div class="value" id="statTotal">0</div><div class="label">Total Actors</div></div>
|
||||
<div class="stat-card"><div class="value" id="statAvgMbox">0</div><div class="label">Avg Mailbox</div></div>
|
||||
<div class="stat-card"><div class="value" id="statMaxMbox">0</div><div class="label">Max Mailbox</div></div>
|
||||
<div class="stat-card"><div class="value" id="statActiveWorkers">0</div><div class="label">Active Workers</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mailbox depth distribution -->
|
||||
<div class="panel">
|
||||
<h2>Mailbox Depth Distribution</h2>
|
||||
<canvas id="depthChart"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- Actors per worker -->
|
||||
<div class="panel">
|
||||
<h2>Actors per Worker</h2>
|
||||
<canvas id="workerChart"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- Actor table -->
|
||||
<div class="panel full-width">
|
||||
<h2>All Actors <span id="actorCount" style="color:#555;font-weight:400;"></span></h2>
|
||||
<div class="search-wrap">
|
||||
<input type="text" id="actorSearch" class="search-input" placeholder="Filter by address or worker..." />
|
||||
<span id="searchInfo" class="search-info"></span>
|
||||
</div>
|
||||
<div class="actor-list-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable" data-sort="address">Address <span id="sortArrowAddress" class="sort-arrow"></span></th>
|
||||
<th class="sortable" data-sort="worker">Worker <span id="sortArrowWorker" class="sort-arrow"></span></th>
|
||||
<th class="sortable" data-sort="mailbox">Mailbox <span id="sortArrowMailbox" class="sort-arrow"></span></th>
|
||||
<th class="sortable" data-sort="msgs">Msgs <span id="sortArrowMsgs" class="sort-arrow"></span></th>
|
||||
<th>Last Msg</th>
|
||||
<th>Depth</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="actorTableBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actor detail panel -->
|
||||
<div id="detailPanel" class="panel full-width detail-panel">
|
||||
<div class="detail-header">
|
||||
<h2>Actor Detail <span id="detailAddr" style="color:#aaa;font-weight:400;"></span></h2>
|
||||
<button class="detail-close" id="detailClose">Close</button>
|
||||
</div>
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item"><div class="d-label">Full Address</div><div class="d-value" id="detailFullAddr" style="font-size:11px;"></div></div>
|
||||
<div class="detail-item"><div class="d-label">Worker</div><div class="d-value" id="detailWorker"></div></div>
|
||||
<div class="detail-item"><div class="d-label">Mailbox Depth</div><div class="d-value" id="detailMailbox"></div></div>
|
||||
<div class="detail-item"><div class="d-label">Messages Processed</div><div class="d-value" id="detailMsgCount"></div></div>
|
||||
<div class="detail-item"><div class="d-label">Last Message Type</div><div class="d-value" id="detailLastMsg"></div></div>
|
||||
<div class="detail-item"><div class="d-label">Status</div><div class="d-value" id="detailStatus"></div></div>
|
||||
</div>
|
||||
<h2>Mailbox Depth History</h2>
|
||||
<canvas id="sparkline" class="sparkline"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var DASHBOARD_MODE = '__DASHBOARD_MODE__';
|
||||
var isReplay = (DASHBOARD_MODE === 'replay');
|
||||
var lastUptimeMs = null;
|
||||
var lastStatsTime = null;
|
||||
|
||||
var dot = document.getElementById('statusDot');
|
||||
var uptimeLabel = document.getElementById('uptimeLabel');
|
||||
var replayBadge = document.getElementById('replayBadge');
|
||||
var replaySpeed = document.getElementById('replaySpeed');
|
||||
var replayPct = document.getElementById('replayPct');
|
||||
var progressBarWrap = document.getElementById('progressBarWrap');
|
||||
var progressFill = document.getElementById('progressFill');
|
||||
|
||||
if (isReplay) {
|
||||
replayBadge.className = 'replay-badge visible';
|
||||
progressBarWrap.className = 'progress-bar-wrap visible';
|
||||
dot.className = 'status-dot replaying';
|
||||
uptimeLabel.style.display = 'none';
|
||||
}
|
||||
|
||||
function updateUptime() {
|
||||
if (isReplay) return;
|
||||
var up = lastUptimeMs;
|
||||
if (up !== null && lastStatsTime !== null) {
|
||||
up += (Date.now() - lastStatsTime);
|
||||
}
|
||||
if (up === null) { uptimeLabel.textContent = ''; return; }
|
||||
var s = Math.floor(up / 1000);
|
||||
var d = Math.floor(s / 86400);
|
||||
var h = Math.floor((s % 86400) / 3600);
|
||||
var m = Math.floor((s % 3600) / 60);
|
||||
var sec = s % 60;
|
||||
var parts = [];
|
||||
if (d > 0) parts.push(d + 'd');
|
||||
if (h > 0 || d > 0) parts.push(h + 'h');
|
||||
parts.push(m + 'm');
|
||||
parts.push(sec + 's');
|
||||
uptimeLabel.textContent = parts.join(' ');
|
||||
}
|
||||
setInterval(updateUptime, 1000);
|
||||
|
||||
// ── State ──────────────────────────────────────────────
|
||||
var currentActors = [];
|
||||
var sortCol = 'worker';
|
||||
var sortAsc = true;
|
||||
var searchTimer = null;
|
||||
var focusedAddrHex = null;
|
||||
var depthHistory = {};
|
||||
var HISTORY_MAX = 120;
|
||||
|
||||
var colors = ['#4caf50','#2196f3','#ff9800','#f44336','#9c27b0','#00bcd4','#ffeb3b','#e91e63'];
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────
|
||||
function addrToHex(addr) {
|
||||
var bytes = Array.isArray(addr) ? addr : Object.values(addr);
|
||||
var hex = '';
|
||||
for (var j = 0; j < Math.min(8, bytes.length); j++) {
|
||||
hex += ('0' + bytes[j].toString(16)).slice(-2);
|
||||
}
|
||||
return hex + '\u2026';
|
||||
}
|
||||
|
||||
function addrToFullHex(addr) {
|
||||
var bytes = Array.isArray(addr) ? addr : Object.values(addr);
|
||||
var hex = '';
|
||||
for (var j = 0; j < bytes.length; j++) {
|
||||
hex += ('0' + bytes[j].toString(16)).slice(-2);
|
||||
}
|
||||
return hex;
|
||||
}
|
||||
|
||||
function shortTypeName(full) {
|
||||
if (!full) return '';
|
||||
var parts = full.split('::');
|
||||
return parts[parts.length - 1];
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
if (!s) return '';
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
function depthColor(d) {
|
||||
if (d === 0) return '#4caf50';
|
||||
if (d <= 2) return '#8bc34a';
|
||||
if (d <= 5) return '#cddc39';
|
||||
if (d <= 10) return '#ff9800';
|
||||
if (d <= 20) return '#ff5722';
|
||||
return '#f44336';
|
||||
}
|
||||
|
||||
// ── Stat cards ─────────────────────────────────────────
|
||||
function updateStatCards(actors) {
|
||||
var total = actors.length;
|
||||
var sum = 0, max = 0;
|
||||
var workerSet = {};
|
||||
for (var i = 0; i < actors.length; i++) {
|
||||
sum += actors[i].mailbox_depth;
|
||||
if (actors[i].mailbox_depth > max) max = actors[i].mailbox_depth;
|
||||
workerSet[actors[i].worker_id] = true;
|
||||
}
|
||||
var avg = total > 0 ? (sum / total).toFixed(1) : '0';
|
||||
document.getElementById('statTotal').textContent = total;
|
||||
document.getElementById('statAvgMbox').textContent = avg;
|
||||
document.getElementById('statMaxMbox').textContent = max;
|
||||
document.getElementById('statActiveWorkers').textContent = Object.keys(workerSet).length;
|
||||
}
|
||||
|
||||
// ── Mailbox depth distribution chart ───────────────────
|
||||
var depthCanvas = document.getElementById('depthChart');
|
||||
var depthCtx = depthCanvas.getContext('2d');
|
||||
|
||||
var buckets = [
|
||||
{label: '0', min: 0, max: 0},
|
||||
{label: '1-2', min: 1, max: 2},
|
||||
{label: '3-5', min: 3, max: 5},
|
||||
{label: '6-10', min: 6, max: 10},
|
||||
{label: '11-20', min: 11, max: 20},
|
||||
{label: '21-50', min: 21, max: 50},
|
||||
{label: '50+', min: 51, max: Infinity}
|
||||
];
|
||||
|
||||
var bucketColors = ['#4caf50','#8bc34a','#cddc39','#ff9800','#ff5722','#f44336','#d32f2f'];
|
||||
|
||||
function drawDepthChart(actors) {
|
||||
var dpr = window.devicePixelRatio || 1;
|
||||
var rect = depthCanvas.getBoundingClientRect();
|
||||
depthCanvas.width = rect.width * dpr;
|
||||
depthCanvas.height = rect.height * dpr;
|
||||
depthCtx.scale(dpr, dpr);
|
||||
var W = rect.width, H = rect.height;
|
||||
depthCtx.clearRect(0, 0, W, H);
|
||||
|
||||
var counts = buckets.map(function() { return 0; });
|
||||
for (var i = 0; i < actors.length; i++) {
|
||||
var d = actors[i].mailbox_depth;
|
||||
for (var b = 0; b < buckets.length; b++) {
|
||||
if (d >= buckets[b].min && d <= buckets[b].max) {
|
||||
counts[b]++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var maxCount = Math.max(1, Math.max.apply(null, counts));
|
||||
var barW = Math.max(12, Math.floor((W - 40) / buckets.length) - 8);
|
||||
var chartH = H - 35;
|
||||
|
||||
for (var i = 0; i < buckets.length; i++) {
|
||||
var x = 20 + i * (barW + 8);
|
||||
var h = (counts[i] / maxCount) * (chartH - 10);
|
||||
depthCtx.fillStyle = bucketColors[i];
|
||||
depthCtx.globalAlpha = 0.85;
|
||||
depthCtx.fillRect(x, chartH - h, barW, h);
|
||||
|
||||
if (counts[i] > 0) {
|
||||
depthCtx.globalAlpha = 1;
|
||||
depthCtx.fillStyle = '#e0e0e0';
|
||||
depthCtx.font = '10px monospace';
|
||||
depthCtx.textAlign = 'center';
|
||||
depthCtx.fillText(counts[i], x + barW / 2, chartH - h - 4);
|
||||
}
|
||||
|
||||
depthCtx.globalAlpha = 1;
|
||||
depthCtx.fillStyle = '#888';
|
||||
depthCtx.font = '10px monospace';
|
||||
depthCtx.textAlign = 'center';
|
||||
depthCtx.fillText(buckets[i].label, x + barW / 2, H - 4);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Actors per worker chart ────────────────────────────
|
||||
var workerCanvas = document.getElementById('workerChart');
|
||||
var workerCtx = workerCanvas.getContext('2d');
|
||||
|
||||
function drawWorkerChart(actors) {
|
||||
var dpr = window.devicePixelRatio || 1;
|
||||
var rect = workerCanvas.getBoundingClientRect();
|
||||
workerCanvas.width = rect.width * dpr;
|
||||
workerCanvas.height = rect.height * dpr;
|
||||
workerCtx.scale(dpr, dpr);
|
||||
var W = rect.width, H = rect.height;
|
||||
workerCtx.clearRect(0, 0, W, H);
|
||||
|
||||
var workerCounts = {};
|
||||
for (var i = 0; i < actors.length; i++) {
|
||||
var wid = actors[i].worker_id;
|
||||
workerCounts[wid] = (workerCounts[wid] || 0) + 1;
|
||||
}
|
||||
var entries = Object.keys(workerCounts).sort(function(a, b) { return +a - +b; })
|
||||
.map(function(wid) { return {id: +wid, count: workerCounts[wid]}; });
|
||||
|
||||
if (entries.length === 0) return;
|
||||
|
||||
var maxCount = Math.max(1, Math.max.apply(null, entries.map(function(e) { return e.count; })));
|
||||
var barW = Math.max(12, Math.floor((W - 40) / entries.length) - 8);
|
||||
var chartH = H - 35;
|
||||
|
||||
for (var i = 0; i < entries.length; i++) {
|
||||
var x = 20 + i * (barW + 8);
|
||||
var h = (entries[i].count / maxCount) * (chartH - 10);
|
||||
workerCtx.fillStyle = colors[entries[i].id % colors.length];
|
||||
workerCtx.globalAlpha = 0.85;
|
||||
workerCtx.fillRect(x, chartH - h, barW, h);
|
||||
|
||||
workerCtx.globalAlpha = 1;
|
||||
workerCtx.fillStyle = '#e0e0e0';
|
||||
workerCtx.font = '10px monospace';
|
||||
workerCtx.textAlign = 'center';
|
||||
workerCtx.fillText(entries[i].count, x + barW / 2, chartH - h - 4);
|
||||
|
||||
workerCtx.fillStyle = '#888';
|
||||
workerCtx.fillText('W' + entries[i].id, x + barW / 2, H - 4);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Actor table ────────────────────────────────────────
|
||||
var MAX_TABLE_ROWS = 2000;
|
||||
|
||||
function renderActorTable() {
|
||||
var filter = document.getElementById('actorSearch').value.toLowerCase();
|
||||
var filtered = currentActors;
|
||||
if (filter) {
|
||||
filtered = currentActors.filter(function(a) {
|
||||
var hex = addrToHex(a.address).toLowerCase();
|
||||
return hex.indexOf(filter) >= 0 || ('w' + a.worker_id).indexOf(filter) >= 0;
|
||||
});
|
||||
}
|
||||
|
||||
// Sort
|
||||
filtered.sort(function(a, b) {
|
||||
var va, vb;
|
||||
if (sortCol === 'address') {
|
||||
va = addrToHex(a.address);
|
||||
vb = addrToHex(b.address);
|
||||
return sortAsc ? va.localeCompare(vb) : vb.localeCompare(va);
|
||||
} else if (sortCol === 'worker') {
|
||||
va = a.worker_id; vb = b.worker_id;
|
||||
} else if (sortCol === 'msgs') {
|
||||
va = a.messages_processed || 0; vb = b.messages_processed || 0;
|
||||
} else {
|
||||
va = a.mailbox_depth; vb = b.mailbox_depth;
|
||||
}
|
||||
return sortAsc ? va - vb : vb - va;
|
||||
});
|
||||
|
||||
// Update sort arrows
|
||||
['address', 'worker', 'mailbox', 'msgs'].forEach(function(col) {
|
||||
var key = col.charAt(0).toUpperCase() + col.slice(1);
|
||||
var el = document.getElementById('sortArrow' + key);
|
||||
if (col === sortCol) {
|
||||
el.textContent = sortAsc ? '\u25B2' : '\u25BC';
|
||||
} else {
|
||||
el.textContent = '';
|
||||
}
|
||||
});
|
||||
|
||||
var maxDepth = 1;
|
||||
for (var i = 0; i < currentActors.length; i++) {
|
||||
if (currentActors[i].mailbox_depth > maxDepth) maxDepth = currentActors[i].mailbox_depth;
|
||||
}
|
||||
|
||||
var tbody = document.getElementById('actorTableBody');
|
||||
tbody.innerHTML = '';
|
||||
var count = Math.min(filtered.length, MAX_TABLE_ROWS);
|
||||
for (var i = 0; i < count; i++) {
|
||||
var a = filtered[i];
|
||||
var hex = addrToHex(a.address);
|
||||
var fullHex = addrToFullHex(a.address);
|
||||
var pct = Math.round((a.mailbox_depth / maxDepth) * 120);
|
||||
var bc = depthColor(a.mailbox_depth);
|
||||
var hasMsg = !!a.last_msg_type;
|
||||
var msgShort = hasMsg ? shortTypeName(a.last_msg_type) : '\u2014';
|
||||
var msgClass = hasMsg ? 'msg-type' : 'msg-type none';
|
||||
var msgTitle = hasMsg ? ' title="' + escapeHtml(a.last_msg_type) + '"' : '';
|
||||
var tr = document.createElement('tr');
|
||||
tr.className = 'clickable' + (fullHex === focusedAddrHex ? ' focused' : '');
|
||||
if (a.poisoned) tr.style.opacity = '0.6';
|
||||
tr.setAttribute('data-addr', fullHex);
|
||||
tr.innerHTML =
|
||||
'<td style="color:#aaa;font-size:11px;">' + escapeHtml(hex) + (a.poisoned ? ' <span style="color:#f44336;font-size:9px;">DEAD</span>' : '') + '</td>' +
|
||||
'<td>W' + a.worker_id + '</td>' +
|
||||
'<td>' + a.mailbox_depth + '</td>' +
|
||||
'<td>' + (a.messages_processed || 0).toLocaleString() + '</td>' +
|
||||
'<td class="' + msgClass + '"' + msgTitle + '>' + escapeHtml(msgShort) + '</td>' +
|
||||
'<td><div class="depth-bar" style="width:' + pct + 'px;background:' + bc + ';"></div></td>';
|
||||
tr.addEventListener('click', (function(fh) { return function() { focusActor(fh); }; })(fullHex));
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
|
||||
if (filtered.length > MAX_TABLE_ROWS) {
|
||||
var tr2 = document.createElement('tr');
|
||||
tr2.innerHTML = '<td colspan="6" style="color:#555;">... and ' + (filtered.length - MAX_TABLE_ROWS) + ' more</td>';
|
||||
tbody.appendChild(tr2);
|
||||
}
|
||||
|
||||
var info = '(' + filtered.length + (filtered.length !== currentActors.length ? ' of ' + currentActors.length : '') + ')';
|
||||
document.getElementById('actorCount').textContent = info;
|
||||
}
|
||||
|
||||
// ── Focus / detail panel ───────────────────────────────
|
||||
function focusActor(fullHex) {
|
||||
focusedAddrHex = fullHex;
|
||||
document.getElementById('detailPanel').className = 'panel full-width detail-panel visible';
|
||||
updateDetailPanel();
|
||||
renderActorTable();
|
||||
}
|
||||
|
||||
function clearFocus() {
|
||||
focusedAddrHex = null;
|
||||
document.getElementById('detailPanel').className = 'panel full-width detail-panel';
|
||||
renderActorTable();
|
||||
}
|
||||
|
||||
document.getElementById('detailClose').addEventListener('click', clearFocus);
|
||||
|
||||
function updateDetailPanel() {
|
||||
if (!focusedAddrHex) return;
|
||||
var actor = null;
|
||||
for (var i = 0; i < currentActors.length; i++) {
|
||||
if (addrToFullHex(currentActors[i].address) === focusedAddrHex) {
|
||||
actor = currentActors[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!actor) {
|
||||
document.getElementById('detailAddr').textContent = focusedAddrHex.substring(0, 16) + '\u2026 (gone)';
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('detailAddr').textContent = addrToHex(actor.address);
|
||||
document.getElementById('detailFullAddr').textContent = focusedAddrHex;
|
||||
document.getElementById('detailWorker').textContent = 'W' + actor.worker_id;
|
||||
document.getElementById('detailMailbox').textContent = actor.mailbox_depth;
|
||||
document.getElementById('detailMsgCount').textContent = (actor.messages_processed || 0).toLocaleString();
|
||||
|
||||
var hasMsg = !!actor.last_msg_type;
|
||||
document.getElementById('detailLastMsg').innerHTML = hasMsg
|
||||
? '<span class="msg-type" title="' + escapeHtml(actor.last_msg_type) + '">' + escapeHtml(shortTypeName(actor.last_msg_type)) + '</span>'
|
||||
: '<span class="msg-type none">\u2014</span>';
|
||||
|
||||
var isPoisoned = !!actor.poisoned;
|
||||
document.getElementById('detailStatus').innerHTML = isPoisoned
|
||||
? '<span class="poisoned-badge">POISONED</span>'
|
||||
: '<span class="healthy-badge">Healthy</span>';
|
||||
|
||||
drawSparkline();
|
||||
}
|
||||
|
||||
// ── Sparkline ──────────────────────────────────────────
|
||||
function drawSparkline() {
|
||||
var canvas = document.getElementById('sparkline');
|
||||
var ctx = canvas.getContext('2d');
|
||||
var dpr = window.devicePixelRatio || 1;
|
||||
var rect = canvas.getBoundingClientRect();
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
var W = rect.width, H = rect.height;
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
|
||||
var hist = depthHistory[focusedAddrHex];
|
||||
if (!hist || hist.length < 2) {
|
||||
ctx.fillStyle = '#555';
|
||||
ctx.font = '11px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('Collecting data\u2026', W / 2, H / 2);
|
||||
return;
|
||||
}
|
||||
|
||||
var max = Math.max(1, Math.max.apply(null, hist));
|
||||
var padY = 6, padX = 4;
|
||||
var drawW = W - padX * 2;
|
||||
var drawH = H - padY * 2;
|
||||
|
||||
// Fill area
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padX, H - padY);
|
||||
for (var i = 0; i < hist.length; i++) {
|
||||
var x = padX + (i / (hist.length - 1)) * drawW;
|
||||
var y = (H - padY) - (hist[i] / max) * drawH;
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.lineTo(padX + drawW, H - padY);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = 'rgba(99, 102, 241, 0.15)';
|
||||
ctx.fill();
|
||||
|
||||
// Line
|
||||
ctx.beginPath();
|
||||
for (var i = 0; i < hist.length; i++) {
|
||||
var x = padX + (i / (hist.length - 1)) * drawW;
|
||||
var y = (H - padY) - (hist[i] / max) * drawH;
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.strokeStyle = '#6366f1';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
|
||||
// Label
|
||||
var last = hist[hist.length - 1];
|
||||
ctx.fillStyle = '#e0e0e0';
|
||||
ctx.font = '10px monospace';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText('depth: ' + last + ' max: ' + max, W - padX, padY + 8);
|
||||
}
|
||||
|
||||
// ── Sort click handlers ────────────────────────────────
|
||||
var sortHeaders = document.querySelectorAll('.sortable');
|
||||
for (var i = 0; i < sortHeaders.length; i++) {
|
||||
sortHeaders[i].addEventListener('click', function() {
|
||||
var col = this.getAttribute('data-sort');
|
||||
if (sortCol === col) { sortAsc = !sortAsc; }
|
||||
else { sortCol = col; sortAsc = true; }
|
||||
renderActorTable();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Search handler ─────────────────────────────────────
|
||||
document.getElementById('actorSearch').addEventListener('keyup', function() {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(renderActorTable, 150);
|
||||
});
|
||||
|
||||
// ── Status helpers ─────────────────────────────────────
|
||||
function setStatus(s) {
|
||||
if (s === 'done') {
|
||||
dot.className = 'status-dot done';
|
||||
if (isReplay) {
|
||||
replayPct.textContent = '100%';
|
||||
progressFill.style.width = '100%';
|
||||
}
|
||||
} else if (s === 'disconnected') {
|
||||
dot.className = 'status-dot disconnected';
|
||||
} else {
|
||||
dot.className = isReplay ? 'status-dot replaying' : 'status-dot';
|
||||
}
|
||||
}
|
||||
|
||||
// ── SSE connection ─────────────────────────────────────
|
||||
var es = new EventSource('/events');
|
||||
|
||||
es.addEventListener('stats', function(e) {
|
||||
try {
|
||||
var data = JSON.parse(e.data);
|
||||
if (typeof data.uptime_ms === 'number') {
|
||||
lastUptimeMs = data.uptime_ms;
|
||||
lastStatsTime = Date.now();
|
||||
}
|
||||
currentActors = data.actor_details || [];
|
||||
updateStatCards(currentActors);
|
||||
drawDepthChart(currentActors);
|
||||
drawWorkerChart(currentActors);
|
||||
|
||||
// Accumulate per-actor depth history
|
||||
var liveAddrs = {};
|
||||
for (var i = 0; i < currentActors.length; i++) {
|
||||
var fh = addrToFullHex(currentActors[i].address);
|
||||
liveAddrs[fh] = true;
|
||||
if (!depthHistory[fh]) depthHistory[fh] = [];
|
||||
depthHistory[fh].push(currentActors[i].mailbox_depth);
|
||||
if (depthHistory[fh].length > HISTORY_MAX) depthHistory[fh].shift();
|
||||
}
|
||||
// Clean up stale entries for removed actors
|
||||
for (var key in depthHistory) {
|
||||
if (!liveAddrs[key]) delete depthHistory[key];
|
||||
}
|
||||
|
||||
renderActorTable();
|
||||
if (focusedAddrHex) updateDetailPanel();
|
||||
} catch(err) { console.error('stats parse error', err); }
|
||||
});
|
||||
|
||||
es.addEventListener('replay_meta', function(e) {
|
||||
try {
|
||||
var meta = JSON.parse(e.data);
|
||||
replaySpeed.textContent = meta.speed + 'x';
|
||||
replaySpeed.style.display = 'inline';
|
||||
replayPct.style.display = 'inline';
|
||||
replayPct.textContent = '0%';
|
||||
} catch(err) { console.error('replay_meta parse error', err); }
|
||||
});
|
||||
|
||||
es.addEventListener('replay_progress', function(e) {
|
||||
try {
|
||||
var data = JSON.parse(e.data);
|
||||
var pct = Math.round(data.progress * 100);
|
||||
replayPct.textContent = pct + '%';
|
||||
progressFill.style.width = pct + '%';
|
||||
} catch(err) { console.error('replay_progress parse error', err); }
|
||||
});
|
||||
|
||||
es.addEventListener('done', function() {
|
||||
setStatus('done');
|
||||
es.close();
|
||||
});
|
||||
|
||||
es.onerror = function() {
|
||||
setStatus('disconnected');
|
||||
};
|
||||
|
||||
es.onopen = function() {
|
||||
setStatus('connected');
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"##;
|
||||
pub const ACTORS_HTML: &str = include_str!("actors.html");
|
||||
|
|
|
|||
511
crates/runtime-dashboard/src/dashboard.html
Normal file
511
crates/runtime-dashboard/src/dashboard.html
Normal file
|
|
@ -0,0 +1,511 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Swactor Runtime Dashboard</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Menlo', 'Consolas', 'Monaco', monospace; background: #0f1117; color: #e0e0e0; font-size: 13px; }
|
||||
|
||||
.header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 12px 20px; background: #161822; border-bottom: 1px solid #2a2d3e;
|
||||
}
|
||||
.header h1 { font-size: 16px; font-weight: 600; color: #fff; }
|
||||
.status-dot {
|
||||
width: 10px; height: 10px; border-radius: 50%; background: #4caf50;
|
||||
display: inline-block; margin-left: 8px; vertical-align: middle;
|
||||
}
|
||||
.status-dot.disconnected { background: #f44336; }
|
||||
.status-dot.done { background: #ff9800; }
|
||||
.status-dot.replaying { background: #2196f3; animation: pulse 1.5s infinite; }
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
.replay-badge {
|
||||
display: none; background: #2196f3; color: #fff; font-size: 10px; font-weight: 700;
|
||||
padding: 2px 8px; border-radius: 3px; margin-left: 10px; letter-spacing: 1px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.replay-badge.visible { display: inline-block; }
|
||||
|
||||
.header-left { display: flex; align-items: center; }
|
||||
.nav-links { display: flex; gap: 4px; margin-left: 20px; }
|
||||
.nav-link {
|
||||
color: #888; text-decoration: none; font-size: 12px;
|
||||
padding: 4px 10px; border-radius: 3px; transition: color 0.2s;
|
||||
}
|
||||
.nav-link:hover { color: #e0e0e0; }
|
||||
.nav-link.active { color: #fff; background: #2a2d3e; }
|
||||
|
||||
.header-right { display: flex; align-items: center; gap: 12px; }
|
||||
|
||||
.progress-bar-wrap {
|
||||
display: none; width: 100%; height: 3px; background: #2a2d3e;
|
||||
}
|
||||
.progress-bar-wrap.visible { display: block; }
|
||||
.progress-fill {
|
||||
height: 100%; width: 0%; background: #2196f3; transition: width 0.3s;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: auto auto;
|
||||
gap: 12px; padding: 12px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: #161822; border: 1px solid #2a2d3e; border-radius: 6px;
|
||||
padding: 14px; overflow: hidden;
|
||||
}
|
||||
.panel h2 { font-size: 12px; color: #888; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 10px; }
|
||||
|
||||
.stats-cards {
|
||||
display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px;
|
||||
}
|
||||
.stat-card {
|
||||
background: #1c1f2e; border-radius: 4px; padding: 10px; text-align: center;
|
||||
}
|
||||
.stat-card .value { font-size: 22px; font-weight: 700; color: #fff; }
|
||||
.stat-card .label { font-size: 10px; color: #888; text-transform: uppercase; margin-top: 2px; }
|
||||
|
||||
.chart-panel { grid-row: span 2; }
|
||||
canvas#workerChart { width: 100%; height: 200px; }
|
||||
|
||||
.actor-table-wrap { max-height: 200px; overflow-y: auto; }
|
||||
.actor-table-wrap table { width: 100%; border-collapse: collapse; }
|
||||
.actor-table-wrap th, .actor-table-wrap td {
|
||||
padding: 4px 8px; text-align: left; border-bottom: 1px solid #2a2d3e; font-size: 12px;
|
||||
}
|
||||
.actor-table-wrap th { color: #888; font-weight: 500; position: sticky; top: 0; background: #161822; }
|
||||
|
||||
.log-panel {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.log-wrap { max-height: 300px; overflow-y: auto; }
|
||||
.log-wrap table { width: 100%; border-collapse: collapse; }
|
||||
.log-wrap th, .log-wrap td {
|
||||
padding: 3px 8px; text-align: left; border-bottom: 1px solid #1c1f2e; font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.log-wrap th { color: #888; font-weight: 500; position: sticky; top: 0; background: #161822; }
|
||||
.log-wrap td.msg { white-space: normal; word-break: break-all; max-width: 400px; }
|
||||
|
||||
.level-ERROR { color: #f44336; font-weight: 700; }
|
||||
.level-WARN { color: #ff9800; }
|
||||
.level-INFO { color: #4caf50; }
|
||||
.level-DEBUG { color: #2196f3; }
|
||||
.level-TRACE { color: #666; }
|
||||
|
||||
.worker-detail-panel { grid-column: 1 / -1; }
|
||||
.worker-detail-scroll { max-height: 360px; overflow-y: auto; }
|
||||
.worker-group { margin-bottom: 8px; border: 1px solid #2a2d3e; border-radius: 4px; overflow: hidden; }
|
||||
.worker-group-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 7px 12px; background: #1c1f2e; cursor: pointer; user-select: none;
|
||||
}
|
||||
.worker-group-header:hover { background: #22253a; }
|
||||
.worker-group-header .wid { font-weight: 700; }
|
||||
.worker-group-header .summary { color: #888; font-size: 11px; }
|
||||
.worker-group-header .toggle { color: #555; font-size: 14px; }
|
||||
.worker-group-body { display: none; }
|
||||
.worker-group.open .worker-group-body { display: block; }
|
||||
.worker-group-body table { width: 100%; border-collapse: collapse; }
|
||||
.worker-group-body th, .worker-group-body td {
|
||||
padding: 3px 10px; text-align: left; border-bottom: 1px solid #1c1f2e; font-size: 11px;
|
||||
}
|
||||
.worker-group-body th { color: #888; font-weight: 500; background: #161822; }
|
||||
.msg-type { color: #4caf50; }
|
||||
.msg-type.none { color: #555; font-style: italic; }
|
||||
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: #0f1117; }
|
||||
::-webkit-scrollbar-thumb { background: #2a2d3e; border-radius: 3px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<h1>
|
||||
Swactor Runtime Dashboard
|
||||
<span id="statusDot" class="status-dot"></span>
|
||||
<span id="replayBadge" class="replay-badge">REPLAY</span>
|
||||
</h1>
|
||||
<nav class="nav-links">
|
||||
<a href="/" class="nav-link active">Overview</a>
|
||||
<a href="/actors" class="nav-link">Actors</a>
|
||||
<a href="/distribution" class="nav-link">Distribution</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<span id="replaySpeed" style="color:#2196f3;font-size:12px;display:none;"></span>
|
||||
<span id="replayPct" style="color:#888;font-size:12px;display:none;"></span>
|
||||
<span id="uptimeLabel" style="color:#888;font-size:12px;"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="progressBarWrap" class="progress-bar-wrap">
|
||||
<div id="progressFill" class="progress-fill"></div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="panel chart-panel">
|
||||
<h2>Worker Distribution</h2>
|
||||
<canvas id="workerChart"></canvas>
|
||||
<div id="workerLegend" style="margin-top:8px;font-size:11px;color:#888;"></div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>Stats</h2>
|
||||
<div class="stats-cards">
|
||||
<div class="stat-card"><div class="value" id="statActors">0</div><div class="label">Actors</div></div>
|
||||
<div class="stat-card"><div class="value" id="statMessages">0</div><div class="label">Messages</div></div>
|
||||
<div class="stat-card"><div class="value" id="statWorkers">0</div><div class="label">Workers</div></div>
|
||||
<div class="stat-card"><div class="value" id="statMailbox">0</div><div class="label">Mailbox</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>Actors</h2>
|
||||
<div class="actor-table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Address</th><th>Worker</th></tr></thead>
|
||||
<tbody id="actorTableBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel worker-detail-panel">
|
||||
<h2>Worker Details</h2>
|
||||
<div class="worker-detail-scroll" id="workerDetailContainer"></div>
|
||||
</div>
|
||||
|
||||
<div class="panel log-panel">
|
||||
<h2>Activity Log <span id="logCount" style="color:#555;font-weight:400;"></span></h2>
|
||||
<div class="log-wrap" id="logWrap">
|
||||
<table>
|
||||
<thead><tr><th>Seq</th><th>Time</th><th>Level</th><th>Worker</th><th>Message</th><th>Fields</th></tr></thead>
|
||||
<tbody id="logTableBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var DASHBOARD_MODE = '__DASHBOARD_MODE__';
|
||||
var logRowCount = 0;
|
||||
var MAX_LOG_ROWS = 2000;
|
||||
var isReplay = (DASHBOARD_MODE === 'replay');
|
||||
var lastUptimeMs = null;
|
||||
var lastStatsTime = null;
|
||||
|
||||
var dot = document.getElementById('statusDot');
|
||||
var uptimeLabel = document.getElementById('uptimeLabel');
|
||||
var replayBadge = document.getElementById('replayBadge');
|
||||
var replaySpeed = document.getElementById('replaySpeed');
|
||||
var replayPct = document.getElementById('replayPct');
|
||||
var progressBarWrap = document.getElementById('progressBarWrap');
|
||||
var progressFill = document.getElementById('progressFill');
|
||||
|
||||
if (isReplay) {
|
||||
replayBadge.className = 'replay-badge visible';
|
||||
progressBarWrap.className = 'progress-bar-wrap visible';
|
||||
dot.className = 'status-dot replaying';
|
||||
uptimeLabel.style.display = 'none';
|
||||
}
|
||||
|
||||
function updateUptime() {
|
||||
if (isReplay) return;
|
||||
var up = lastUptimeMs;
|
||||
if (up !== null && lastStatsTime !== null) {
|
||||
up += (Date.now() - lastStatsTime);
|
||||
}
|
||||
if (up === null) { uptimeLabel.textContent = ''; return; }
|
||||
var s = Math.floor(up / 1000);
|
||||
var d = Math.floor(s / 86400);
|
||||
var h = Math.floor((s % 86400) / 3600);
|
||||
var m = Math.floor((s % 3600) / 60);
|
||||
var sec = s % 60;
|
||||
var parts = [];
|
||||
if (d > 0) parts.push(d + 'd');
|
||||
if (h > 0 || d > 0) parts.push(h + 'h');
|
||||
parts.push(m + 'm');
|
||||
parts.push(sec + 's');
|
||||
uptimeLabel.textContent = parts.join(' ');
|
||||
}
|
||||
setInterval(updateUptime, 1000);
|
||||
|
||||
var canvas = document.getElementById('workerChart');
|
||||
var ctx = canvas.getContext('2d');
|
||||
var colors = ['#4caf50','#2196f3','#ff9800','#f44336','#9c27b0','#00bcd4','#ffeb3b','#e91e63'];
|
||||
|
||||
function drawWorkerChart(workers) {
|
||||
var dpr = window.devicePixelRatio || 1;
|
||||
var rect = canvas.getBoundingClientRect();
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
var W = rect.width, H = rect.height;
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
|
||||
if (!workers || workers.length === 0) return;
|
||||
|
||||
var maxActors = Math.max(1, Math.max.apply(null, workers.map(function(w) { return w.num_actors; })));
|
||||
var barW = Math.max(8, Math.floor((W - 40) / workers.length) - 6);
|
||||
var chartH = H - 30;
|
||||
|
||||
workers.forEach(function(w, i) {
|
||||
var x = 20 + i * (barW + 6);
|
||||
var h = (w.num_actors / maxActors) * (chartH * 0.45);
|
||||
ctx.fillStyle = colors[i % colors.length];
|
||||
ctx.globalAlpha = 0.8;
|
||||
ctx.fillRect(x, chartH * 0.5 - h, barW, h);
|
||||
|
||||
var mh = Math.min(w.mailbox_depth * 2, chartH * 0.4);
|
||||
ctx.globalAlpha = 0.4;
|
||||
ctx.fillRect(x, chartH * 0.55, barW, mh);
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = '#888';
|
||||
ctx.font = '10px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('W' + w.id, x + barW / 2, H - 2);
|
||||
});
|
||||
|
||||
var legend = document.getElementById('workerLegend');
|
||||
legend.innerHTML = workers.map(function(w, i) {
|
||||
return '<span style="color:' + colors[i % colors.length] + '">W' + w.id +
|
||||
': ' + w.num_actors + ' actors, ' + w.messages_processed + ' msgs, mbox ' + w.mailbox_depth + '</span>';
|
||||
}).join(' | ');
|
||||
}
|
||||
|
||||
function updateStats(data) {
|
||||
if (typeof data.uptime_ms === 'number') {
|
||||
lastUptimeMs = data.uptime_ms;
|
||||
lastStatsTime = Date.now();
|
||||
}
|
||||
var totalActors = data.actors ? data.actors.length : 0;
|
||||
var totalMsgs = data.workers ? data.workers.reduce(function(s, w) { return s + w.messages_processed; }, 0) : 0;
|
||||
var totalMailbox = data.workers ? data.workers.reduce(function(s, w) { return s + w.mailbox_depth; }, 0) : 0;
|
||||
|
||||
document.getElementById('statActors').textContent = totalActors;
|
||||
document.getElementById('statMessages').textContent = totalMsgs.toLocaleString();
|
||||
document.getElementById('statWorkers').textContent = data.num_workers || 0;
|
||||
document.getElementById('statMailbox').textContent = totalMailbox;
|
||||
|
||||
drawWorkerChart(data.workers);
|
||||
|
||||
var tbody = document.getElementById('actorTableBody');
|
||||
tbody.innerHTML = '';
|
||||
if (data.actors) {
|
||||
var shown = data.actors.slice(0, 200);
|
||||
shown.forEach(function(entry) {
|
||||
var addr = entry[0];
|
||||
var wid = entry[1];
|
||||
var hex = '';
|
||||
if (addr && addr.length > 0) {
|
||||
var bytes = Array.isArray(addr) ? addr : Object.values(addr);
|
||||
for (var j = 0; j < Math.min(8, bytes.length); j++) {
|
||||
hex += ('0' + bytes[j].toString(16)).slice(-2);
|
||||
}
|
||||
hex += '\u2026';
|
||||
}
|
||||
var tr = document.createElement('tr');
|
||||
tr.innerHTML = '<td style="color:#aaa;font-size:11px;">' + hex + '</td><td>W' + wid + '</td>';
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
if (data.actors.length > 200) {
|
||||
var tr2 = document.createElement('tr');
|
||||
tr2.innerHTML = '<td colspan="2" style="color:#555;">... and ' + (data.actors.length - 200) + ' more</td>';
|
||||
tbody.appendChild(tr2);
|
||||
}
|
||||
}
|
||||
|
||||
updateWorkerDetails(data);
|
||||
}
|
||||
|
||||
function formatAddr(addr) {
|
||||
var hex = '';
|
||||
if (addr && addr.length > 0) {
|
||||
var bytes = Array.isArray(addr) ? addr : Object.values(addr);
|
||||
for (var j = 0; j < Math.min(8, bytes.length); j++) {
|
||||
hex += ('0' + bytes[j].toString(16)).slice(-2);
|
||||
}
|
||||
hex += '\u2026';
|
||||
}
|
||||
return hex;
|
||||
}
|
||||
|
||||
function shortTypeName(full) {
|
||||
if (!full) return '';
|
||||
var parts = full.split('::');
|
||||
return parts[parts.length - 1];
|
||||
}
|
||||
|
||||
function updateWorkerDetails(data) {
|
||||
var container = document.getElementById('workerDetailContainer');
|
||||
if (!data.workers) return;
|
||||
|
||||
var groups = {};
|
||||
data.workers.forEach(function(w) { groups[w.id] = { info: w, actors: [] }; });
|
||||
if (data.actor_details) {
|
||||
data.actor_details.forEach(function(a) {
|
||||
if (groups[a.worker_id]) groups[a.worker_id].actors.push(a);
|
||||
});
|
||||
}
|
||||
|
||||
var openState = {};
|
||||
container.querySelectorAll('.worker-group').forEach(function(g) {
|
||||
openState[g.dataset.wid] = g.classList.contains('open');
|
||||
});
|
||||
|
||||
container.innerHTML = '';
|
||||
var wids = Object.keys(groups).sort(function(a, b) { return a - b; });
|
||||
|
||||
wids.forEach(function(wid) {
|
||||
var g = groups[wid];
|
||||
var div = document.createElement('div');
|
||||
var isOpen = openState[wid] || false;
|
||||
div.className = 'worker-group' + (isOpen ? ' open' : '');
|
||||
div.dataset.wid = wid;
|
||||
|
||||
var hdr = document.createElement('div');
|
||||
hdr.className = 'worker-group-header';
|
||||
var panicHtml = g.info.panics > 0 ? ', <span style="color:#f44336">' + g.info.panics + ' panics</span>' : '';
|
||||
hdr.innerHTML =
|
||||
'<span class="wid" style="color:' + colors[wid % colors.length] + '">W' + wid + '</span>' +
|
||||
'<span class="summary">' + g.actors.length + ' actors, ' +
|
||||
g.info.messages_processed.toLocaleString() + ' msgs, mbox ' + g.info.mailbox_depth + panicHtml + '</span>' +
|
||||
'<span class="toggle">' + (isOpen ? '\u25BC' : '\u25B6') + '</span>';
|
||||
hdr.onclick = function() {
|
||||
div.classList.toggle('open');
|
||||
hdr.querySelector('.toggle').textContent = div.classList.contains('open') ? '\u25BC' : '\u25B6';
|
||||
};
|
||||
div.appendChild(hdr);
|
||||
|
||||
var body = document.createElement('div');
|
||||
body.className = 'worker-group-body';
|
||||
if (g.actors.length === 0) {
|
||||
body.innerHTML = '<div style="padding:6px 12px;color:#555;">No actors</div>';
|
||||
} else {
|
||||
var rows = '';
|
||||
g.actors.forEach(function(a) {
|
||||
var hex = formatAddr(a.address);
|
||||
var hasMsg = !!a.last_msg_type;
|
||||
var msgShort = hasMsg ? shortTypeName(a.last_msg_type) : 'none';
|
||||
var msgClass = hasMsg ? 'msg-type' : 'msg-type none';
|
||||
var title = hasMsg ? ' title="' + escapeHtml(a.last_msg_type) + '"' : '';
|
||||
rows += '<tr><td style="color:#aaa;">' + hex + '</td>' +
|
||||
'<td>' + a.mailbox_depth + '</td>' +
|
||||
'<td class="' + msgClass + '"' + title + '>' + escapeHtml(msgShort) + '</td></tr>';
|
||||
});
|
||||
body.innerHTML = '<table><thead><tr><th>Address</th><th>Mailbox</th><th>Last Message</th></tr></thead><tbody>' + rows + '</tbody></table>';
|
||||
}
|
||||
div.appendChild(body);
|
||||
container.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
function addLogEvents(events) {
|
||||
var tbody = document.getElementById('logTableBody');
|
||||
var wrap = document.getElementById('logWrap');
|
||||
var wasAtBottom = wrap.scrollTop + wrap.clientHeight >= wrap.scrollHeight - 20;
|
||||
|
||||
events.forEach(function(ev) {
|
||||
if (logRowCount >= MAX_LOG_ROWS) {
|
||||
tbody.removeChild(tbody.firstChild);
|
||||
logRowCount--;
|
||||
}
|
||||
var tr = document.createElement('tr');
|
||||
var ts = new Date(ev.timestamp_ms);
|
||||
var timeStr = ts.toLocaleTimeString() + '.' + String(ts.getMilliseconds()).padStart(3, '0');
|
||||
var wid = ev.worker_id !== null && ev.worker_id !== undefined ? 'W' + ev.worker_id : '-';
|
||||
var fieldsStr = Object.keys(ev.fields).length > 0 ? JSON.stringify(ev.fields) : '';
|
||||
|
||||
tr.innerHTML =
|
||||
'<td>' + ev.seq + '</td>' +
|
||||
'<td>' + timeStr + '</td>' +
|
||||
'<td class="level-' + ev.level + '">' + ev.level + '</td>' +
|
||||
'<td>' + wid + '</td>' +
|
||||
'<td class="msg">' + escapeHtml(ev.message) + '</td>' +
|
||||
'<td style="color:#555;max-width:300px;overflow:hidden;text-overflow:ellipsis;">' + escapeHtml(fieldsStr) + '</td>';
|
||||
tbody.appendChild(tr);
|
||||
logRowCount++;
|
||||
});
|
||||
|
||||
document.getElementById('logCount').textContent = '(' + logRowCount + ')';
|
||||
|
||||
if (wasAtBottom) {
|
||||
wrap.scrollTop = wrap.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
if (!s) return '';
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
function setStatus(s) {
|
||||
if (s === 'done') {
|
||||
dot.className = 'status-dot done';
|
||||
if (isReplay) {
|
||||
replayPct.textContent = '100%';
|
||||
progressFill.style.width = '100%';
|
||||
}
|
||||
} else if (s === 'disconnected') {
|
||||
dot.className = 'status-dot disconnected';
|
||||
} else {
|
||||
dot.className = isReplay ? 'status-dot replaying' : 'status-dot';
|
||||
}
|
||||
}
|
||||
|
||||
var es = new EventSource('/events');
|
||||
|
||||
es.addEventListener('stats', function(e) {
|
||||
try { updateStats(JSON.parse(e.data)); } catch(err) { console.error('stats parse error', err); }
|
||||
});
|
||||
|
||||
es.addEventListener('activity', function(e) {
|
||||
try { addLogEvents(JSON.parse(e.data)); } catch(err) { console.error('activity parse error', err); }
|
||||
});
|
||||
|
||||
es.addEventListener('replay_meta', function(e) {
|
||||
try {
|
||||
var meta = JSON.parse(e.data);
|
||||
replaySpeed.textContent = meta.speed + 'x';
|
||||
replaySpeed.style.display = 'inline';
|
||||
replayPct.style.display = 'inline';
|
||||
replayPct.textContent = '0%';
|
||||
} catch(err) { console.error('replay_meta parse error', err); }
|
||||
});
|
||||
|
||||
es.addEventListener('replay_progress', function(e) {
|
||||
try {
|
||||
var data = JSON.parse(e.data);
|
||||
var pct = Math.round(data.progress * 100);
|
||||
replayPct.textContent = pct + '%';
|
||||
progressFill.style.width = pct + '%';
|
||||
} catch(err) { console.error('replay_progress parse error', err); }
|
||||
});
|
||||
|
||||
es.addEventListener('done', function() {
|
||||
setStatus('done');
|
||||
es.close();
|
||||
});
|
||||
|
||||
es.onerror = function() {
|
||||
setStatus('disconnected');
|
||||
};
|
||||
|
||||
es.onopen = function() {
|
||||
setStatus('connected');
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,512 +1 @@
|
|||
pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Swactor Runtime Dashboard</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Menlo', 'Consolas', 'Monaco', monospace; background: #0f1117; color: #e0e0e0; font-size: 13px; }
|
||||
|
||||
.header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 12px 20px; background: #161822; border-bottom: 1px solid #2a2d3e;
|
||||
}
|
||||
.header h1 { font-size: 16px; font-weight: 600; color: #fff; }
|
||||
.status-dot {
|
||||
width: 10px; height: 10px; border-radius: 50%; background: #4caf50;
|
||||
display: inline-block; margin-left: 8px; vertical-align: middle;
|
||||
}
|
||||
.status-dot.disconnected { background: #f44336; }
|
||||
.status-dot.done { background: #ff9800; }
|
||||
.status-dot.replaying { background: #2196f3; animation: pulse 1.5s infinite; }
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
.replay-badge {
|
||||
display: none; background: #2196f3; color: #fff; font-size: 10px; font-weight: 700;
|
||||
padding: 2px 8px; border-radius: 3px; margin-left: 10px; letter-spacing: 1px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.replay-badge.visible { display: inline-block; }
|
||||
|
||||
.header-left { display: flex; align-items: center; }
|
||||
.nav-links { display: flex; gap: 4px; margin-left: 20px; }
|
||||
.nav-link {
|
||||
color: #888; text-decoration: none; font-size: 12px;
|
||||
padding: 4px 10px; border-radius: 3px; transition: color 0.2s;
|
||||
}
|
||||
.nav-link:hover { color: #e0e0e0; }
|
||||
.nav-link.active { color: #fff; background: #2a2d3e; }
|
||||
|
||||
.header-right { display: flex; align-items: center; gap: 12px; }
|
||||
|
||||
.progress-bar-wrap {
|
||||
display: none; width: 100%; height: 3px; background: #2a2d3e;
|
||||
}
|
||||
.progress-bar-wrap.visible { display: block; }
|
||||
.progress-fill {
|
||||
height: 100%; width: 0%; background: #2196f3; transition: width 0.3s;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: auto auto;
|
||||
gap: 12px; padding: 12px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: #161822; border: 1px solid #2a2d3e; border-radius: 6px;
|
||||
padding: 14px; overflow: hidden;
|
||||
}
|
||||
.panel h2 { font-size: 12px; color: #888; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 10px; }
|
||||
|
||||
.stats-cards {
|
||||
display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px;
|
||||
}
|
||||
.stat-card {
|
||||
background: #1c1f2e; border-radius: 4px; padding: 10px; text-align: center;
|
||||
}
|
||||
.stat-card .value { font-size: 22px; font-weight: 700; color: #fff; }
|
||||
.stat-card .label { font-size: 10px; color: #888; text-transform: uppercase; margin-top: 2px; }
|
||||
|
||||
.chart-panel { grid-row: span 2; }
|
||||
canvas#workerChart { width: 100%; height: 200px; }
|
||||
|
||||
.actor-table-wrap { max-height: 200px; overflow-y: auto; }
|
||||
.actor-table-wrap table { width: 100%; border-collapse: collapse; }
|
||||
.actor-table-wrap th, .actor-table-wrap td {
|
||||
padding: 4px 8px; text-align: left; border-bottom: 1px solid #2a2d3e; font-size: 12px;
|
||||
}
|
||||
.actor-table-wrap th { color: #888; font-weight: 500; position: sticky; top: 0; background: #161822; }
|
||||
|
||||
.log-panel {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.log-wrap { max-height: 300px; overflow-y: auto; }
|
||||
.log-wrap table { width: 100%; border-collapse: collapse; }
|
||||
.log-wrap th, .log-wrap td {
|
||||
padding: 3px 8px; text-align: left; border-bottom: 1px solid #1c1f2e; font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.log-wrap th { color: #888; font-weight: 500; position: sticky; top: 0; background: #161822; }
|
||||
.log-wrap td.msg { white-space: normal; word-break: break-all; max-width: 400px; }
|
||||
|
||||
.level-ERROR { color: #f44336; font-weight: 700; }
|
||||
.level-WARN { color: #ff9800; }
|
||||
.level-INFO { color: #4caf50; }
|
||||
.level-DEBUG { color: #2196f3; }
|
||||
.level-TRACE { color: #666; }
|
||||
|
||||
.worker-detail-panel { grid-column: 1 / -1; }
|
||||
.worker-detail-scroll { max-height: 360px; overflow-y: auto; }
|
||||
.worker-group { margin-bottom: 8px; border: 1px solid #2a2d3e; border-radius: 4px; overflow: hidden; }
|
||||
.worker-group-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 7px 12px; background: #1c1f2e; cursor: pointer; user-select: none;
|
||||
}
|
||||
.worker-group-header:hover { background: #22253a; }
|
||||
.worker-group-header .wid { font-weight: 700; }
|
||||
.worker-group-header .summary { color: #888; font-size: 11px; }
|
||||
.worker-group-header .toggle { color: #555; font-size: 14px; }
|
||||
.worker-group-body { display: none; }
|
||||
.worker-group.open .worker-group-body { display: block; }
|
||||
.worker-group-body table { width: 100%; border-collapse: collapse; }
|
||||
.worker-group-body th, .worker-group-body td {
|
||||
padding: 3px 10px; text-align: left; border-bottom: 1px solid #1c1f2e; font-size: 11px;
|
||||
}
|
||||
.worker-group-body th { color: #888; font-weight: 500; background: #161822; }
|
||||
.msg-type { color: #4caf50; }
|
||||
.msg-type.none { color: #555; font-style: italic; }
|
||||
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: #0f1117; }
|
||||
::-webkit-scrollbar-thumb { background: #2a2d3e; border-radius: 3px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<h1>
|
||||
Swactor Runtime Dashboard
|
||||
<span id="statusDot" class="status-dot"></span>
|
||||
<span id="replayBadge" class="replay-badge">REPLAY</span>
|
||||
</h1>
|
||||
<nav class="nav-links">
|
||||
<a href="/" class="nav-link active">Overview</a>
|
||||
<a href="/actors" class="nav-link">Actors</a>
|
||||
<a href="/distribution" class="nav-link">Distribution</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<span id="replaySpeed" style="color:#2196f3;font-size:12px;display:none;"></span>
|
||||
<span id="replayPct" style="color:#888;font-size:12px;display:none;"></span>
|
||||
<span id="uptimeLabel" style="color:#888;font-size:12px;"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="progressBarWrap" class="progress-bar-wrap">
|
||||
<div id="progressFill" class="progress-fill"></div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="panel chart-panel">
|
||||
<h2>Worker Distribution</h2>
|
||||
<canvas id="workerChart"></canvas>
|
||||
<div id="workerLegend" style="margin-top:8px;font-size:11px;color:#888;"></div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>Stats</h2>
|
||||
<div class="stats-cards">
|
||||
<div class="stat-card"><div class="value" id="statActors">0</div><div class="label">Actors</div></div>
|
||||
<div class="stat-card"><div class="value" id="statMessages">0</div><div class="label">Messages</div></div>
|
||||
<div class="stat-card"><div class="value" id="statWorkers">0</div><div class="label">Workers</div></div>
|
||||
<div class="stat-card"><div class="value" id="statMailbox">0</div><div class="label">Mailbox</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>Actors</h2>
|
||||
<div class="actor-table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Address</th><th>Worker</th></tr></thead>
|
||||
<tbody id="actorTableBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel worker-detail-panel">
|
||||
<h2>Worker Details</h2>
|
||||
<div class="worker-detail-scroll" id="workerDetailContainer"></div>
|
||||
</div>
|
||||
|
||||
<div class="panel log-panel">
|
||||
<h2>Activity Log <span id="logCount" style="color:#555;font-weight:400;"></span></h2>
|
||||
<div class="log-wrap" id="logWrap">
|
||||
<table>
|
||||
<thead><tr><th>Seq</th><th>Time</th><th>Level</th><th>Worker</th><th>Message</th><th>Fields</th></tr></thead>
|
||||
<tbody id="logTableBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var DASHBOARD_MODE = '__DASHBOARD_MODE__';
|
||||
var logRowCount = 0;
|
||||
var MAX_LOG_ROWS = 2000;
|
||||
var isReplay = (DASHBOARD_MODE === 'replay');
|
||||
var lastUptimeMs = null;
|
||||
var lastStatsTime = null;
|
||||
|
||||
var dot = document.getElementById('statusDot');
|
||||
var uptimeLabel = document.getElementById('uptimeLabel');
|
||||
var replayBadge = document.getElementById('replayBadge');
|
||||
var replaySpeed = document.getElementById('replaySpeed');
|
||||
var replayPct = document.getElementById('replayPct');
|
||||
var progressBarWrap = document.getElementById('progressBarWrap');
|
||||
var progressFill = document.getElementById('progressFill');
|
||||
|
||||
if (isReplay) {
|
||||
replayBadge.className = 'replay-badge visible';
|
||||
progressBarWrap.className = 'progress-bar-wrap visible';
|
||||
dot.className = 'status-dot replaying';
|
||||
uptimeLabel.style.display = 'none';
|
||||
}
|
||||
|
||||
function updateUptime() {
|
||||
if (isReplay) return;
|
||||
var up = lastUptimeMs;
|
||||
if (up !== null && lastStatsTime !== null) {
|
||||
up += (Date.now() - lastStatsTime);
|
||||
}
|
||||
if (up === null) { uptimeLabel.textContent = ''; return; }
|
||||
var s = Math.floor(up / 1000);
|
||||
var d = Math.floor(s / 86400);
|
||||
var h = Math.floor((s % 86400) / 3600);
|
||||
var m = Math.floor((s % 3600) / 60);
|
||||
var sec = s % 60;
|
||||
var parts = [];
|
||||
if (d > 0) parts.push(d + 'd');
|
||||
if (h > 0 || d > 0) parts.push(h + 'h');
|
||||
parts.push(m + 'm');
|
||||
parts.push(sec + 's');
|
||||
uptimeLabel.textContent = parts.join(' ');
|
||||
}
|
||||
setInterval(updateUptime, 1000);
|
||||
|
||||
var canvas = document.getElementById('workerChart');
|
||||
var ctx = canvas.getContext('2d');
|
||||
var colors = ['#4caf50','#2196f3','#ff9800','#f44336','#9c27b0','#00bcd4','#ffeb3b','#e91e63'];
|
||||
|
||||
function drawWorkerChart(workers) {
|
||||
var dpr = window.devicePixelRatio || 1;
|
||||
var rect = canvas.getBoundingClientRect();
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
var W = rect.width, H = rect.height;
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
|
||||
if (!workers || workers.length === 0) return;
|
||||
|
||||
var maxActors = Math.max(1, Math.max.apply(null, workers.map(function(w) { return w.num_actors; })));
|
||||
var barW = Math.max(8, Math.floor((W - 40) / workers.length) - 6);
|
||||
var chartH = H - 30;
|
||||
|
||||
workers.forEach(function(w, i) {
|
||||
var x = 20 + i * (barW + 6);
|
||||
var h = (w.num_actors / maxActors) * (chartH * 0.45);
|
||||
ctx.fillStyle = colors[i % colors.length];
|
||||
ctx.globalAlpha = 0.8;
|
||||
ctx.fillRect(x, chartH * 0.5 - h, barW, h);
|
||||
|
||||
var mh = Math.min(w.mailbox_depth * 2, chartH * 0.4);
|
||||
ctx.globalAlpha = 0.4;
|
||||
ctx.fillRect(x, chartH * 0.55, barW, mh);
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = '#888';
|
||||
ctx.font = '10px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('W' + w.id, x + barW / 2, H - 2);
|
||||
});
|
||||
|
||||
var legend = document.getElementById('workerLegend');
|
||||
legend.innerHTML = workers.map(function(w, i) {
|
||||
return '<span style="color:' + colors[i % colors.length] + '">W' + w.id +
|
||||
': ' + w.num_actors + ' actors, ' + w.messages_processed + ' msgs, mbox ' + w.mailbox_depth + '</span>';
|
||||
}).join(' | ');
|
||||
}
|
||||
|
||||
function updateStats(data) {
|
||||
if (typeof data.uptime_ms === 'number') {
|
||||
lastUptimeMs = data.uptime_ms;
|
||||
lastStatsTime = Date.now();
|
||||
}
|
||||
var totalActors = data.actors ? data.actors.length : 0;
|
||||
var totalMsgs = data.workers ? data.workers.reduce(function(s, w) { return s + w.messages_processed; }, 0) : 0;
|
||||
var totalMailbox = data.workers ? data.workers.reduce(function(s, w) { return s + w.mailbox_depth; }, 0) : 0;
|
||||
|
||||
document.getElementById('statActors').textContent = totalActors;
|
||||
document.getElementById('statMessages').textContent = totalMsgs.toLocaleString();
|
||||
document.getElementById('statWorkers').textContent = data.num_workers || 0;
|
||||
document.getElementById('statMailbox').textContent = totalMailbox;
|
||||
|
||||
drawWorkerChart(data.workers);
|
||||
|
||||
var tbody = document.getElementById('actorTableBody');
|
||||
tbody.innerHTML = '';
|
||||
if (data.actors) {
|
||||
var shown = data.actors.slice(0, 200);
|
||||
shown.forEach(function(entry) {
|
||||
var addr = entry[0];
|
||||
var wid = entry[1];
|
||||
var hex = '';
|
||||
if (addr && addr.length > 0) {
|
||||
var bytes = Array.isArray(addr) ? addr : Object.values(addr);
|
||||
for (var j = 0; j < Math.min(8, bytes.length); j++) {
|
||||
hex += ('0' + bytes[j].toString(16)).slice(-2);
|
||||
}
|
||||
hex += '\u2026';
|
||||
}
|
||||
var tr = document.createElement('tr');
|
||||
tr.innerHTML = '<td style="color:#aaa;font-size:11px;">' + hex + '</td><td>W' + wid + '</td>';
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
if (data.actors.length > 200) {
|
||||
var tr2 = document.createElement('tr');
|
||||
tr2.innerHTML = '<td colspan="2" style="color:#555;">... and ' + (data.actors.length - 200) + ' more</td>';
|
||||
tbody.appendChild(tr2);
|
||||
}
|
||||
}
|
||||
|
||||
updateWorkerDetails(data);
|
||||
}
|
||||
|
||||
function formatAddr(addr) {
|
||||
var hex = '';
|
||||
if (addr && addr.length > 0) {
|
||||
var bytes = Array.isArray(addr) ? addr : Object.values(addr);
|
||||
for (var j = 0; j < Math.min(8, bytes.length); j++) {
|
||||
hex += ('0' + bytes[j].toString(16)).slice(-2);
|
||||
}
|
||||
hex += '\u2026';
|
||||
}
|
||||
return hex;
|
||||
}
|
||||
|
||||
function shortTypeName(full) {
|
||||
if (!full) return '';
|
||||
var parts = full.split('::');
|
||||
return parts[parts.length - 1];
|
||||
}
|
||||
|
||||
function updateWorkerDetails(data) {
|
||||
var container = document.getElementById('workerDetailContainer');
|
||||
if (!data.workers) return;
|
||||
|
||||
var groups = {};
|
||||
data.workers.forEach(function(w) { groups[w.id] = { info: w, actors: [] }; });
|
||||
if (data.actor_details) {
|
||||
data.actor_details.forEach(function(a) {
|
||||
if (groups[a.worker_id]) groups[a.worker_id].actors.push(a);
|
||||
});
|
||||
}
|
||||
|
||||
var openState = {};
|
||||
container.querySelectorAll('.worker-group').forEach(function(g) {
|
||||
openState[g.dataset.wid] = g.classList.contains('open');
|
||||
});
|
||||
|
||||
container.innerHTML = '';
|
||||
var wids = Object.keys(groups).sort(function(a, b) { return a - b; });
|
||||
|
||||
wids.forEach(function(wid) {
|
||||
var g = groups[wid];
|
||||
var div = document.createElement('div');
|
||||
var isOpen = openState[wid] || false;
|
||||
div.className = 'worker-group' + (isOpen ? ' open' : '');
|
||||
div.dataset.wid = wid;
|
||||
|
||||
var hdr = document.createElement('div');
|
||||
hdr.className = 'worker-group-header';
|
||||
var panicHtml = g.info.panics > 0 ? ', <span style="color:#f44336">' + g.info.panics + ' panics</span>' : '';
|
||||
hdr.innerHTML =
|
||||
'<span class="wid" style="color:' + colors[wid % colors.length] + '">W' + wid + '</span>' +
|
||||
'<span class="summary">' + g.actors.length + ' actors, ' +
|
||||
g.info.messages_processed.toLocaleString() + ' msgs, mbox ' + g.info.mailbox_depth + panicHtml + '</span>' +
|
||||
'<span class="toggle">' + (isOpen ? '\u25BC' : '\u25B6') + '</span>';
|
||||
hdr.onclick = function() {
|
||||
div.classList.toggle('open');
|
||||
hdr.querySelector('.toggle').textContent = div.classList.contains('open') ? '\u25BC' : '\u25B6';
|
||||
};
|
||||
div.appendChild(hdr);
|
||||
|
||||
var body = document.createElement('div');
|
||||
body.className = 'worker-group-body';
|
||||
if (g.actors.length === 0) {
|
||||
body.innerHTML = '<div style="padding:6px 12px;color:#555;">No actors</div>';
|
||||
} else {
|
||||
var rows = '';
|
||||
g.actors.forEach(function(a) {
|
||||
var hex = formatAddr(a.address);
|
||||
var hasMsg = !!a.last_msg_type;
|
||||
var msgShort = hasMsg ? shortTypeName(a.last_msg_type) : 'none';
|
||||
var msgClass = hasMsg ? 'msg-type' : 'msg-type none';
|
||||
var title = hasMsg ? ' title="' + escapeHtml(a.last_msg_type) + '"' : '';
|
||||
rows += '<tr><td style="color:#aaa;">' + hex + '</td>' +
|
||||
'<td>' + a.mailbox_depth + '</td>' +
|
||||
'<td class="' + msgClass + '"' + title + '>' + escapeHtml(msgShort) + '</td></tr>';
|
||||
});
|
||||
body.innerHTML = '<table><thead><tr><th>Address</th><th>Mailbox</th><th>Last Message</th></tr></thead><tbody>' + rows + '</tbody></table>';
|
||||
}
|
||||
div.appendChild(body);
|
||||
container.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
function addLogEvents(events) {
|
||||
var tbody = document.getElementById('logTableBody');
|
||||
var wrap = document.getElementById('logWrap');
|
||||
var wasAtBottom = wrap.scrollTop + wrap.clientHeight >= wrap.scrollHeight - 20;
|
||||
|
||||
events.forEach(function(ev) {
|
||||
if (logRowCount >= MAX_LOG_ROWS) {
|
||||
tbody.removeChild(tbody.firstChild);
|
||||
logRowCount--;
|
||||
}
|
||||
var tr = document.createElement('tr');
|
||||
var ts = new Date(ev.timestamp_ms);
|
||||
var timeStr = ts.toLocaleTimeString() + '.' + String(ts.getMilliseconds()).padStart(3, '0');
|
||||
var wid = ev.worker_id !== null && ev.worker_id !== undefined ? 'W' + ev.worker_id : '-';
|
||||
var fieldsStr = Object.keys(ev.fields).length > 0 ? JSON.stringify(ev.fields) : '';
|
||||
|
||||
tr.innerHTML =
|
||||
'<td>' + ev.seq + '</td>' +
|
||||
'<td>' + timeStr + '</td>' +
|
||||
'<td class="level-' + ev.level + '">' + ev.level + '</td>' +
|
||||
'<td>' + wid + '</td>' +
|
||||
'<td class="msg">' + escapeHtml(ev.message) + '</td>' +
|
||||
'<td style="color:#555;max-width:300px;overflow:hidden;text-overflow:ellipsis;">' + escapeHtml(fieldsStr) + '</td>';
|
||||
tbody.appendChild(tr);
|
||||
logRowCount++;
|
||||
});
|
||||
|
||||
document.getElementById('logCount').textContent = '(' + logRowCount + ')';
|
||||
|
||||
if (wasAtBottom) {
|
||||
wrap.scrollTop = wrap.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
if (!s) return '';
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
function setStatus(s) {
|
||||
if (s === 'done') {
|
||||
dot.className = 'status-dot done';
|
||||
if (isReplay) {
|
||||
replayPct.textContent = '100%';
|
||||
progressFill.style.width = '100%';
|
||||
}
|
||||
} else if (s === 'disconnected') {
|
||||
dot.className = 'status-dot disconnected';
|
||||
} else {
|
||||
dot.className = isReplay ? 'status-dot replaying' : 'status-dot';
|
||||
}
|
||||
}
|
||||
|
||||
var es = new EventSource('/events');
|
||||
|
||||
es.addEventListener('stats', function(e) {
|
||||
try { updateStats(JSON.parse(e.data)); } catch(err) { console.error('stats parse error', err); }
|
||||
});
|
||||
|
||||
es.addEventListener('activity', function(e) {
|
||||
try { addLogEvents(JSON.parse(e.data)); } catch(err) { console.error('activity parse error', err); }
|
||||
});
|
||||
|
||||
es.addEventListener('replay_meta', function(e) {
|
||||
try {
|
||||
var meta = JSON.parse(e.data);
|
||||
replaySpeed.textContent = meta.speed + 'x';
|
||||
replaySpeed.style.display = 'inline';
|
||||
replayPct.style.display = 'inline';
|
||||
replayPct.textContent = '0%';
|
||||
} catch(err) { console.error('replay_meta parse error', err); }
|
||||
});
|
||||
|
||||
es.addEventListener('replay_progress', function(e) {
|
||||
try {
|
||||
var data = JSON.parse(e.data);
|
||||
var pct = Math.round(data.progress * 100);
|
||||
replayPct.textContent = pct + '%';
|
||||
progressFill.style.width = pct + '%';
|
||||
} catch(err) { console.error('replay_progress parse error', err); }
|
||||
});
|
||||
|
||||
es.addEventListener('done', function() {
|
||||
setStatus('done');
|
||||
es.close();
|
||||
});
|
||||
|
||||
es.onerror = function() {
|
||||
setStatus('disconnected');
|
||||
};
|
||||
|
||||
es.onopen = function() {
|
||||
setStatus('connected');
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"##;
|
||||
pub const DASHBOARD_HTML: &str = include_str!("dashboard.html");
|
||||
|
|
|
|||
746
crates/runtime-dashboard/src/distribution.html
Normal file
746
crates/runtime-dashboard/src/distribution.html
Normal file
|
|
@ -0,0 +1,746 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Swactor Runtime – Distribution</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Menlo', 'Consolas', 'Monaco', monospace; background: #0f1117; color: #e0e0e0; font-size: 13px; }
|
||||
|
||||
.header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 12px 20px; background: #161822; border-bottom: 1px solid #2a2d3e;
|
||||
}
|
||||
.header-left { display: flex; align-items: center; }
|
||||
.header h1 { font-size: 16px; font-weight: 600; color: #fff; }
|
||||
.status-dot {
|
||||
width: 10px; height: 10px; border-radius: 50%; background: #4caf50;
|
||||
display: inline-block; margin-left: 8px; vertical-align: middle;
|
||||
}
|
||||
.status-dot.disconnected { background: #f44336; }
|
||||
.status-dot.done { background: #ff9800; }
|
||||
|
||||
.nav-links { display: flex; gap: 4px; margin-left: 20px; }
|
||||
.nav-link {
|
||||
color: #888; text-decoration: none; font-size: 12px;
|
||||
padding: 4px 10px; border-radius: 3px; transition: color 0.2s;
|
||||
}
|
||||
.nav-link:hover { color: #e0e0e0; }
|
||||
.nav-link.active { color: #fff; background: #2a2d3e; }
|
||||
|
||||
.header-right { display: flex; align-items: center; gap: 12px; }
|
||||
|
||||
.main {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
height: calc(100vh - 48px);
|
||||
}
|
||||
|
||||
.graph-panel {
|
||||
grid-row: 1 / 3; border-right: 1px solid #2a2d3e; position: relative;
|
||||
min-height: 0;
|
||||
}
|
||||
.graph-panel canvas { width: 100%; height: 100%; display: block; }
|
||||
|
||||
.side-panel { display: flex; flex-direction: column; overflow: hidden; min-height: 0; }
|
||||
|
||||
.stats-panel {
|
||||
flex-shrink: 0; padding: 12px 16px; border-bottom: 1px solid #2a2d3e; background: #161822;
|
||||
}
|
||||
.stats-panel h2 { font-size: 12px; color: #888; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; }
|
||||
|
||||
.stats-cards {
|
||||
display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px;
|
||||
}
|
||||
.stat-card {
|
||||
background: #1c1f2e; border-radius: 4px; padding: 10px; text-align: center;
|
||||
}
|
||||
.stat-card .value { font-size: 22px; font-weight: 700; color: #fff; }
|
||||
.stat-card .label { font-size: 10px; color: #888; text-transform: uppercase; margin-top: 2px; }
|
||||
|
||||
.table-panel {
|
||||
flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden;
|
||||
}
|
||||
.table-panel h2 {
|
||||
font-size: 12px; color: #888; text-transform: uppercase; letter-spacing: 1px;
|
||||
padding: 10px 16px 6px; flex-shrink: 0;
|
||||
}
|
||||
.table-scroll {
|
||||
flex: 1; overflow-y: auto; padding: 0 16px 8px;
|
||||
}
|
||||
.table-scroll table { width: 100%; border-collapse: collapse; }
|
||||
.table-scroll th, .table-scroll td {
|
||||
padding: 3px 8px; text-align: left; border-bottom: 1px solid #1c1f2e; font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.table-scroll th { color: #888; font-weight: 500; position: sticky; top: 0; background: #161822; }
|
||||
|
||||
.state-alive { color: #4caf50; }
|
||||
.state-suspect { color: #ff9800; }
|
||||
.state-dead { color: #f44336; }
|
||||
|
||||
.bottom-panel {
|
||||
grid-column: 1 / -1; border-top: 1px solid #2a2d3e; background: #161822;
|
||||
display: flex; gap: 12px; padding: 12px 16px; height: 200px;
|
||||
}
|
||||
.bottom-section { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||
.bottom-section h2 {
|
||||
font-size: 12px; color: #888; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 6px;
|
||||
}
|
||||
.bottom-section canvas { flex: 1; width: 100%; }
|
||||
.bottom-section .scroll-wrap {
|
||||
flex: 1; overflow-y: auto; font-size: 11px;
|
||||
}
|
||||
|
||||
.ego-hint {
|
||||
position: absolute; bottom: 12px; left: 12px; font-size: 10px;
|
||||
color: #555; pointer-events: none;
|
||||
}
|
||||
|
||||
.node-id-label {
|
||||
position: absolute; top: 12px; left: 12px; font-size: 11px;
|
||||
color: #888; max-width: 50%; overflow: hidden; text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: #0f1117; }
|
||||
::-webkit-scrollbar-thumb { background: #2a2d3e; border-radius: 3px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<h1>
|
||||
Swactor Runtime Dashboard
|
||||
<span id="statusDot" class="status-dot disconnected"></span>
|
||||
</h1>
|
||||
<nav class="nav-links">
|
||||
<a href="/" class="nav-link">Overview</a>
|
||||
<a href="/actors" class="nav-link">Actors</a>
|
||||
<a href="/distribution" class="nav-link active">Distribution</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<span id="nodeLabel" style="color:#888;font-size:12px;">Waiting for data...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
<!-- Left: Graph -->
|
||||
<div class="graph-panel">
|
||||
<canvas id="graphCanvas"></canvas>
|
||||
<div class="ego-hint" id="egoHint">Click a node for ego-centric view. Double-click to reset.</div>
|
||||
<div class="node-id-label" id="selfLabel"></div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Stats + Tables -->
|
||||
<div class="side-panel">
|
||||
<div class="stats-panel">
|
||||
<h2>Distribution Stats</h2>
|
||||
<div class="stats-cards">
|
||||
<div class="stat-card"><div class="value" id="statMembers">0</div><div class="label">Members</div></div>
|
||||
<div class="stat-card"><div class="value" id="statAlive">0</div><div class="label">Alive</div></div>
|
||||
<div class="stat-card"><div class="value" id="statSuspect">0</div><div class="label">Suspect</div></div>
|
||||
<div class="stat-card"><div class="value" id="statDead">0</div><div class="label">Dead</div></div>
|
||||
<div class="stat-card"><div class="value" id="statCache">0</div><div class="label">Cache</div></div>
|
||||
<div class="stat-card"><div class="value" id="statRT">0</div><div class="label">RT Size</div></div>
|
||||
<div class="stat-card"><div class="value" id="statDir">0</div><div class="label">Directory</div></div>
|
||||
<div class="stat-card"><div class="value" id="statRepair">0</div><div class="label">Repair Q</div></div>
|
||||
<div class="stat-card"><div class="value" id="statProbes">0</div><div class="label">Probes</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-panel">
|
||||
<h2>Members <span id="memberCount" style="color:#555;font-weight:400;"></span></h2>
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead><tr><th>State</th><th>Node ID</th><th>Address</th><th>Inc</th></tr></thead>
|
||||
<tbody id="membersBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom: Cache, Gossip Pairs, Routing Histogram -->
|
||||
<div class="bottom-panel">
|
||||
<div class="bottom-section">
|
||||
<h2>LRU Cache <span id="cacheCount" style="color:#555;font-weight:400;"></span></h2>
|
||||
<div class="scroll-wrap">
|
||||
<table style="width:100%;border-collapse:collapse;">
|
||||
<thead><tr><th style="color:#888;font-weight:500;font-size:11px;">Actor</th><th style="color:#888;font-weight:500;font-size:11px;">Node</th></tr></thead>
|
||||
<tbody id="cacheBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bottom-section">
|
||||
<h2>Recent Probes</h2>
|
||||
<div class="scroll-wrap" id="probesWrap"></div>
|
||||
</div>
|
||||
<div class="bottom-section">
|
||||
<h2>Routing Buckets</h2>
|
||||
<canvas id="bucketChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var DASHBOARD_MODE = '__DASHBOARD_MODE__';
|
||||
var dot = document.getElementById('statusDot');
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────
|
||||
var data = null; // latest DistributionNodeSnapshot
|
||||
var selfNodeId = ''; // this node's hex id
|
||||
var focusIdx = -1; // ego-centric focus (-1 = none, 0 = self)
|
||||
|
||||
// Graph state
|
||||
var N = 0;
|
||||
var posX = new Float64Array(0);
|
||||
var posY = new Float64Array(0);
|
||||
var nodeIds = []; // hex strings
|
||||
var nodeStates = []; // 'alive' | 'suspect' | 'dead' | 'self'
|
||||
var nodeAddrs = [];
|
||||
var layoutDone = false;
|
||||
var layoutIter = 0;
|
||||
|
||||
// View transform
|
||||
var vx = 0, vy = 0, vs = 1;
|
||||
var isDragging = false, dragX = 0, dragY = 0, dragVx = 0, dragVy = 0;
|
||||
var clickStartX = 0, clickStartY = 0;
|
||||
|
||||
// Routing neighbor set (for ego highlight)
|
||||
var routingSet = {};
|
||||
|
||||
// ── Quadtree (Barnes-Hut) ──────────────────────────────────────
|
||||
var QF = 11;
|
||||
var qt = new Float64Array(256 * QF);
|
||||
var qtN = 0;
|
||||
var _fx = 0, _fy = 0;
|
||||
|
||||
function qtAlloc(ox, oy, sz) {
|
||||
if (qtN * QF >= qt.length) {
|
||||
var nq = new Float64Array(Math.max(qt.length * 2, 256 * QF));
|
||||
nq.set(qt); qt = nq;
|
||||
}
|
||||
var i = qtN++, o = i * QF;
|
||||
qt[o]=ox; qt[o+1]=oy; qt[o+2]=sz;
|
||||
qt[o+3]=0; qt[o+4]=0; qt[o+5]=0;
|
||||
qt[o+6]=-1; qt[o+7]=-1; qt[o+8]=-1; qt[o+9]=-1;
|
||||
qt[o+10]=-1;
|
||||
return i;
|
||||
}
|
||||
|
||||
function qtBuild() {
|
||||
var x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
||||
for (var i = 0; i < N; i++) {
|
||||
if (posX[i] < x0) x0 = posX[i]; if (posY[i] < y0) y0 = posY[i];
|
||||
if (posX[i] > x1) x1 = posX[i]; if (posY[i] > y1) y1 = posY[i];
|
||||
}
|
||||
var sz = Math.max(x1 - x0, y1 - y0, 1) + 2;
|
||||
qtN = 0;
|
||||
qtAlloc(x0 - 1, y0 - 1, sz);
|
||||
for (var i = 0; i < N; i++) qtIns(0, i, posX[i], posY[i]);
|
||||
}
|
||||
|
||||
function qtIns(ni, bi, bx, by) {
|
||||
var o = ni * QF;
|
||||
if (qt[o+5] === 0) { qt[o+3] = bx; qt[o+4] = by; qt[o+5] = 1; qt[o+10] = bi; return; }
|
||||
if (qt[o+10] >= 0) {
|
||||
if (qt[o+2] < 0.001) { qt[o+5]++; return; }
|
||||
var eb = qt[o+10], ex = qt[o+3], ey = qt[o+4];
|
||||
qt[o+10] = -1;
|
||||
qtInsChild(ni, eb, ex, ey);
|
||||
}
|
||||
var m = qt[o+5];
|
||||
qt[o+3] = (qt[o+3]*m + bx) / (m+1);
|
||||
qt[o+4] = (qt[o+4]*m + by) / (m+1);
|
||||
qt[o+5] = m + 1;
|
||||
qtInsChild(ni, bi, bx, by);
|
||||
}
|
||||
|
||||
function qtInsChild(ni, bi, bx, by) {
|
||||
var o = ni * QF, hs = qt[o+2] / 2;
|
||||
var mx = qt[o] + hs, my = qt[o+1] + hs;
|
||||
var qx = bx < mx ? 0 : 1, qy = by < my ? 0 : 1;
|
||||
var ci = o + 6 + qy * 2 + qx;
|
||||
if (qt[ci] < 0) qt[ci] = qtAlloc(qx ? mx : qt[o], qy ? my : qt[o+1], hs);
|
||||
qtIns(qt[ci], bi, bx, by);
|
||||
}
|
||||
|
||||
function qtCalc(ni, px, py, k2, th2) {
|
||||
if (ni < 0) return;
|
||||
var o = ni * QF;
|
||||
if (qt[o+5] === 0) return;
|
||||
var dx = qt[o+3] - px, dy = qt[o+4] - py;
|
||||
var d2 = dx*dx + dy*dy;
|
||||
if (d2 < 0.0001) d2 = 0.0001;
|
||||
if (qt[o+10] >= 0 || qt[o+2]*qt[o+2]/d2 < th2) {
|
||||
var d = Math.sqrt(d2), f = -(k2 * qt[o+5]) / d2;
|
||||
_fx += (dx/d)*f; _fy += (dy/d)*f;
|
||||
return;
|
||||
}
|
||||
for (var c = 6; c < 10; c++) if (qt[o+c] >= 0) qtCalc(qt[o+c], px, py, k2, th2);
|
||||
}
|
||||
|
||||
// ── Canvas setup ───────────────────────────────────────────────
|
||||
var canvas = document.getElementById('graphCanvas');
|
||||
var ctx = canvas.getContext('2d');
|
||||
|
||||
function resizeCanvas() {
|
||||
var r = canvas.parentElement.getBoundingClientRect();
|
||||
canvas.width = r.width * devicePixelRatio;
|
||||
canvas.height = r.height * devicePixelRatio;
|
||||
canvas.style.width = r.width + 'px';
|
||||
canvas.style.height = r.height + 'px';
|
||||
}
|
||||
window.addEventListener('resize', function() { resizeCanvas(); drawGraph(); });
|
||||
|
||||
// ── Zoom & pan ─────────────────────────────────────────────────
|
||||
canvas.addEventListener('wheel', function(e) {
|
||||
e.preventDefault();
|
||||
var r = canvas.getBoundingClientRect();
|
||||
var mx = e.clientX - r.left, my = e.clientY - r.top;
|
||||
var f = e.deltaY < 0 ? 1.1 : 1 / 1.1;
|
||||
var ns = Math.max(0.05, Math.min(5, vs * f));
|
||||
var ratio = ns / vs;
|
||||
vx = mx - (mx - vx) * ratio;
|
||||
vy = my - (my - vy) * ratio;
|
||||
vs = ns;
|
||||
drawGraph();
|
||||
}, { passive: false });
|
||||
|
||||
canvas.addEventListener('mousedown', function(e) {
|
||||
if (e.button !== 0) return;
|
||||
isDragging = true;
|
||||
dragX = e.clientX; dragY = e.clientY;
|
||||
clickStartX = e.clientX; clickStartY = e.clientY;
|
||||
dragVx = vx; dragVy = vy;
|
||||
canvas.style.cursor = 'grabbing';
|
||||
});
|
||||
window.addEventListener('mousemove', function(e) {
|
||||
if (!isDragging) return;
|
||||
vx = dragVx + (e.clientX - dragX);
|
||||
vy = dragVy + (e.clientY - dragY);
|
||||
drawGraph();
|
||||
});
|
||||
window.addEventListener('mouseup', function(e) {
|
||||
if (isDragging) {
|
||||
var wasDrag = Math.abs(e.clientX - clickStartX) > 3 || Math.abs(e.clientY - clickStartY) > 3;
|
||||
isDragging = false; canvas.style.cursor = '';
|
||||
if (!wasDrag) handleClick(e);
|
||||
}
|
||||
});
|
||||
canvas.addEventListener('dblclick', function() { focusIdx = -1; resetView(); drawGraph(); });
|
||||
|
||||
function resetView() {
|
||||
if (N === 0) { vx = 0; vy = 0; vs = 1; return; }
|
||||
var r = canvas.parentElement.getBoundingClientRect();
|
||||
var w = r.width, h = r.height;
|
||||
var x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
||||
for (var i = 0; i < N; i++) {
|
||||
if (posX[i] < x0) x0 = posX[i]; if (posY[i] < y0) y0 = posY[i];
|
||||
if (posX[i] > x1) x1 = posX[i]; if (posY[i] > y1) y1 = posY[i];
|
||||
}
|
||||
if (!isFinite(x0)) { vx = 0; vy = 0; vs = 1; return; }
|
||||
var pad = 40;
|
||||
vs = Math.min(w / (x1 - x0 + pad * 2), h / (y1 - y0 + pad * 2), 2);
|
||||
vx = (w - (x0 + x1) * vs) / 2;
|
||||
vy = (h - (y0 + y1) * vs) / 2;
|
||||
}
|
||||
|
||||
// ── Click handler (ego-centric selection) ──────────────────────
|
||||
function handleClick(e) {
|
||||
var r = canvas.getBoundingClientRect();
|
||||
var mx = e.clientX - r.left, my = e.clientY - r.top;
|
||||
var wx = (mx - vx) / vs, wy = (my - vy) / vs;
|
||||
var baseR = Math.max(4, Math.min(12, 400 / Math.sqrt(Math.max(N, 1))));
|
||||
var hitR = baseR * 2;
|
||||
var best = -1, bestD = hitR * hitR;
|
||||
for (var i = 0; i < N; i++) {
|
||||
var dx = posX[i] - wx, dy = posY[i] - wy;
|
||||
var d2 = dx*dx + dy*dy;
|
||||
if (d2 < bestD) { bestD = d2; best = i; }
|
||||
}
|
||||
if (best < 0) { focusIdx = -1; }
|
||||
else { focusIdx = best; }
|
||||
drawGraph();
|
||||
}
|
||||
|
||||
// ── Force layout ───────────────────────────────────────────────
|
||||
function initPositions(w, h) {
|
||||
var cx = w / 2, cy = h / 2;
|
||||
var rad = Math.min(cx, cy) * 0.6;
|
||||
for (var i = 0; i < N; i++) {
|
||||
var a = (2 * Math.PI * i) / N - Math.PI / 2;
|
||||
posX[i] = cx + rad * Math.cos(a);
|
||||
posY[i] = cy + rad * Math.sin(a);
|
||||
}
|
||||
}
|
||||
|
||||
function runLayout() {
|
||||
var r = canvas.parentElement.getBoundingClientRect();
|
||||
var w = r.width, h = r.height;
|
||||
if (w === 0 || h === 0) return;
|
||||
var cx = w/2, cy = h/2;
|
||||
var k = Math.sqrt(w*h / Math.max(N, 1)), k2 = k*k;
|
||||
var totalIters = Math.min(200, Math.max(30, Math.floor(20000 / Math.max(N, 1))));
|
||||
var useBH = N > 60, th2 = 0.64;
|
||||
var dx = new Float64Array(N), dy = new Float64Array(N);
|
||||
|
||||
// Edges: self→each member (star topology from this node's perspective)
|
||||
var selfIdx = 0; // node 0 is always 'self'
|
||||
var edges = [];
|
||||
for (var i = 1; i < N; i++) edges.push([selfIdx, i]);
|
||||
|
||||
for (var iter = 0; iter < totalIters; iter++) {
|
||||
dx.fill(0); dy.fill(0);
|
||||
|
||||
if (useBH) {
|
||||
qtBuild();
|
||||
for (var i = 0; i < N; i++) {
|
||||
_fx = 0; _fy = 0;
|
||||
qtCalc(0, posX[i], posY[i], k2, th2);
|
||||
dx[i] += _fx; dy[i] += _fy;
|
||||
}
|
||||
} else {
|
||||
for (var i = 0; i < N; i++) for (var j = i+1; j < N; j++) {
|
||||
var ddx = posX[i]-posX[j], ddy = posY[i]-posY[j];
|
||||
var dist = Math.sqrt(ddx*ddx + ddy*ddy) || 0.01;
|
||||
var f = k2/dist, fx = (ddx/dist)*f, fy = (ddy/dist)*f;
|
||||
dx[i] += fx; dy[i] += fy; dx[j] -= fx; dy[j] -= fy;
|
||||
}
|
||||
}
|
||||
|
||||
for (var e = 0; e < edges.length; e++) {
|
||||
var si = edges[e][0], di = edges[e][1];
|
||||
var ddx = posX[si]-posX[di], ddy = posY[si]-posY[di];
|
||||
var dist = Math.sqrt(ddx*ddx + ddy*ddy) || 0.01;
|
||||
var f = (dist*dist)/k, fx = (ddx/dist)*f, fy = (ddy/dist)*f;
|
||||
dx[si] -= fx; dy[si] -= fy; dx[di] += fx; dy[di] += fy;
|
||||
}
|
||||
|
||||
for (var i = 0; i < N; i++) {
|
||||
dx[i] -= (posX[i]-cx)*0.01; dy[i] -= (posY[i]-cy)*0.01;
|
||||
}
|
||||
|
||||
var temp = Math.max(0.1, 1 - iter/totalIters);
|
||||
for (var i = 0; i < N; i++) {
|
||||
var dist = Math.sqrt(dx[i]*dx[i] + dy[i]*dy[i]) || 0.01;
|
||||
var cap = Math.min(dist, 10*temp);
|
||||
posX[i] += (dx[i]/dist)*cap; posY[i] += (dy[i]/dist)*cap;
|
||||
posX[i] = Math.max(40, Math.min(w-40, posX[i]));
|
||||
posY[i] = Math.max(40, Math.min(h-40, posY[i]));
|
||||
}
|
||||
}
|
||||
|
||||
layoutDone = true;
|
||||
resetView();
|
||||
}
|
||||
|
||||
// ── Draw graph ─────────────────────────────────────────────────
|
||||
var stateColors = { 'alive': '#4caf50', 'suspect': '#ff9800', 'dead': '#f44336', 'self': '#6366f1' };
|
||||
|
||||
function drawGraph() {
|
||||
var dpr = devicePixelRatio || 1;
|
||||
var r = canvas.parentElement.getBoundingClientRect();
|
||||
if (canvas.width !== Math.round(r.width * dpr)) resizeCanvas();
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, r.width, r.height);
|
||||
|
||||
if (N === 0) {
|
||||
ctx.fillStyle = '#555';
|
||||
ctx.font = '13px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('No distribution data yet', r.width / 2, r.height / 2);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(vx, vy);
|
||||
ctx.scale(vs, vs);
|
||||
|
||||
var baseR = Math.max(4, Math.min(12, 400 / Math.sqrt(Math.max(N, 1))));
|
||||
|
||||
// Determine highlighted set for ego mode
|
||||
var highlighted = null;
|
||||
if (focusIdx >= 0) {
|
||||
highlighted = {};
|
||||
highlighted[focusIdx] = true;
|
||||
// If focus is self (0), highlight all connected members
|
||||
// If focus is a member, highlight self and that member
|
||||
if (focusIdx === 0) {
|
||||
for (var i = 1; i < N; i++) highlighted[i] = true;
|
||||
} else {
|
||||
highlighted[0] = true; // always show self
|
||||
}
|
||||
}
|
||||
|
||||
// Draw edges (self → each member)
|
||||
for (var i = 1; i < N; i++) {
|
||||
var alpha = 1;
|
||||
var dash = false;
|
||||
if (highlighted) {
|
||||
if (!highlighted[i]) { alpha = 0.08; }
|
||||
else {
|
||||
// Check if this node is a routing neighbor
|
||||
var nid = nodeIds[i];
|
||||
if (routingSet[nid]) { dash = true; alpha = 0.7; }
|
||||
else { alpha = 0.5; }
|
||||
}
|
||||
} else {
|
||||
alpha = 0.2;
|
||||
}
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(posX[0], posY[0]);
|
||||
ctx.lineTo(posX[i], posY[i]);
|
||||
ctx.strokeStyle = 'rgba(99,102,241,' + alpha + ')';
|
||||
ctx.lineWidth = 1 / vs;
|
||||
if (dash) { ctx.setLineDash([4/vs, 4/vs]); }
|
||||
else { ctx.setLineDash([]); }
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Draw nodes
|
||||
for (var i = 0; i < N; i++) {
|
||||
var opacity = 1;
|
||||
if (highlighted && !highlighted[i]) opacity = 0.15;
|
||||
|
||||
var col = stateColors[nodeStates[i]] || '#888';
|
||||
var isSelf = (nodeStates[i] === 'self');
|
||||
|
||||
ctx.beginPath();
|
||||
var nr = isSelf ? baseR * 1.5 : baseR;
|
||||
ctx.arc(posX[i], posY[i], nr, 0, Math.PI * 2);
|
||||
ctx.globalAlpha = opacity * 0.85;
|
||||
ctx.fillStyle = col;
|
||||
ctx.fill();
|
||||
|
||||
if (isSelf) {
|
||||
ctx.lineWidth = 2 / vs;
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.globalAlpha = opacity * 0.6;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
if (focusIdx === i) {
|
||||
ctx.lineWidth = 2 / vs;
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.globalAlpha = opacity;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// Label (short ID)
|
||||
if (vs > 0.5) {
|
||||
ctx.fillStyle = '#ccc';
|
||||
ctx.globalAlpha = opacity;
|
||||
ctx.font = Math.round(9 / vs) + 'px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
var label = nodeIds[i] ? nodeIds[i].substring(0, 8) : '';
|
||||
ctx.fillText(label, posX[i], posY[i] - nr - 3/vs);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// ── Update from snapshot ───────────────────────────────────────
|
||||
function updateFromSnapshot(d) {
|
||||
data = d;
|
||||
|
||||
// Build node arrays: index 0 = self, then members
|
||||
var oldN = N;
|
||||
var newIds = [d.node_id];
|
||||
var newStates = ['self'];
|
||||
var newAddrs = [d.listen_addr];
|
||||
for (var i = 0; i < d.members.length; i++) {
|
||||
newIds.push(d.members[i].node_id);
|
||||
newStates.push(d.members[i].state);
|
||||
newAddrs.push(d.members[i].addr);
|
||||
}
|
||||
|
||||
// Build routing neighbor set
|
||||
routingSet = {};
|
||||
for (var i = 0; i < d.routing_neighbors.length; i++) {
|
||||
routingSet[d.routing_neighbors[i].node_id] = true;
|
||||
}
|
||||
|
||||
selfNodeId = d.node_id;
|
||||
|
||||
// Check if topology changed
|
||||
var changed = newIds.length !== N;
|
||||
if (!changed) {
|
||||
for (var i = 0; i < newIds.length; i++) {
|
||||
if (newIds[i] !== nodeIds[i]) { changed = true; break; }
|
||||
}
|
||||
}
|
||||
|
||||
nodeIds = newIds;
|
||||
nodeStates = newStates;
|
||||
nodeAddrs = newAddrs;
|
||||
N = nodeIds.length;
|
||||
|
||||
if (changed || !layoutDone) {
|
||||
posX = new Float64Array(N);
|
||||
posY = new Float64Array(N);
|
||||
resizeCanvas();
|
||||
initPositions(canvas.parentElement.getBoundingClientRect().width,
|
||||
canvas.parentElement.getBoundingClientRect().height);
|
||||
runLayout();
|
||||
} else {
|
||||
// Just update states, redraw
|
||||
drawGraph();
|
||||
}
|
||||
|
||||
updateUI(d);
|
||||
}
|
||||
|
||||
function updateUI(d) {
|
||||
// Self label
|
||||
document.getElementById('selfLabel').textContent = 'Node: ' + d.node_id.substring(0, 16) + '\u2026';
|
||||
document.getElementById('nodeLabel').textContent = d.listen_addr;
|
||||
|
||||
// Stats cards
|
||||
document.getElementById('statMembers').textContent = d.members.length;
|
||||
document.getElementById('statAlive').textContent = d.alive_count;
|
||||
document.getElementById('statSuspect').textContent = d.suspect_count;
|
||||
document.getElementById('statDead').textContent = d.dead_count;
|
||||
document.getElementById('statCache').textContent = d.cache_size;
|
||||
document.getElementById('statRT').textContent = d.routing_table_size;
|
||||
document.getElementById('statDir').textContent = d.directory_entry_count;
|
||||
document.getElementById('statRepair').textContent = d.repair_queue_size;
|
||||
document.getElementById('statProbes').textContent = d.recent_probe_targets.length;
|
||||
|
||||
// Color alive/suspect/dead
|
||||
document.getElementById('statAlive').style.color = '#4caf50';
|
||||
document.getElementById('statSuspect').style.color = d.suspect_count > 0 ? '#ff9800' : '#fff';
|
||||
document.getElementById('statDead').style.color = d.dead_count > 0 ? '#f44336' : '#fff';
|
||||
|
||||
// Members table
|
||||
var body = document.getElementById('membersBody');
|
||||
body.innerHTML = '';
|
||||
document.getElementById('memberCount').textContent = '(' + d.members.length + ')';
|
||||
for (var i = 0; i < d.members.length; i++) {
|
||||
var m = d.members[i];
|
||||
var cls = 'state-' + m.state;
|
||||
var tr = document.createElement('tr');
|
||||
tr.innerHTML =
|
||||
'<td class="' + cls + '">' + m.state + '</td>' +
|
||||
'<td style="color:#aaa;font-size:10px;">' + m.node_id.substring(0, 16) + '\u2026</td>' +
|
||||
'<td>' + m.addr + '</td>' +
|
||||
'<td>' + m.incarnation + '</td>';
|
||||
body.appendChild(tr);
|
||||
}
|
||||
|
||||
// Cache table
|
||||
var cacheBody = document.getElementById('cacheBody');
|
||||
cacheBody.innerHTML = '';
|
||||
document.getElementById('cacheCount').textContent = '(' + d.cache_entries.length + ')';
|
||||
var cacheMax = Math.min(d.cache_entries.length, 200);
|
||||
for (var i = 0; i < cacheMax; i++) {
|
||||
var e = d.cache_entries[i];
|
||||
var tr = document.createElement('tr');
|
||||
tr.innerHTML =
|
||||
'<td style="color:#aaa;font-size:10px;max-width:120px;overflow:hidden;text-overflow:ellipsis;">' + e.actor_addr + '</td>' +
|
||||
'<td style="color:#aaa;font-size:10px;">' + e.node_id.substring(0, 12) + '\u2026</td>';
|
||||
cacheBody.appendChild(tr);
|
||||
}
|
||||
|
||||
// Recent probes
|
||||
var probesWrap = document.getElementById('probesWrap');
|
||||
probesWrap.innerHTML = '';
|
||||
for (var i = d.recent_probe_targets.length - 1; i >= 0; i--) {
|
||||
var div = document.createElement('div');
|
||||
div.style.cssText = 'padding:2px 0;color:#aaa;font-size:11px;border-bottom:1px solid #1c1f2e;';
|
||||
div.textContent = d.recent_probe_targets[i].substring(0, 16) + '\u2026';
|
||||
probesWrap.appendChild(div);
|
||||
}
|
||||
|
||||
// Routing bucket histogram
|
||||
drawBucketChart(d.routing_buckets);
|
||||
}
|
||||
|
||||
// ── Bucket histogram ───────────────────────────────────────────
|
||||
function drawBucketChart(buckets) {
|
||||
var cv = document.getElementById('bucketChart');
|
||||
var bctx = cv.getContext('2d');
|
||||
var dpr = devicePixelRatio || 1;
|
||||
var rect = cv.parentElement.getBoundingClientRect();
|
||||
var rw = rect.width, rh = cv.parentElement.clientHeight - 20;
|
||||
cv.width = rw * dpr;
|
||||
cv.height = rh * dpr;
|
||||
cv.style.width = rw + 'px';
|
||||
cv.style.height = rh + 'px';
|
||||
bctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
bctx.clearRect(0, 0, rw, rh);
|
||||
|
||||
if (!buckets || buckets.length === 0) {
|
||||
bctx.fillStyle = '#555';
|
||||
bctx.font = '11px monospace';
|
||||
bctx.textAlign = 'center';
|
||||
bctx.fillText('No buckets', rw / 2, rh / 2);
|
||||
return;
|
||||
}
|
||||
|
||||
var maxCount = 1;
|
||||
for (var i = 0; i < buckets.length; i++) {
|
||||
if (buckets[i][1] > maxCount) maxCount = buckets[i][1];
|
||||
}
|
||||
|
||||
var barW = Math.max(4, Math.floor((rw - 20) / buckets.length) - 2);
|
||||
var chartH = rh - 20;
|
||||
|
||||
for (var i = 0; i < buckets.length; i++) {
|
||||
var x = 10 + i * (barW + 2);
|
||||
var h = (buckets[i][1] / maxCount) * (chartH - 4);
|
||||
bctx.fillStyle = '#6366f1';
|
||||
bctx.globalAlpha = 0.8;
|
||||
bctx.fillRect(x, chartH - h, barW, h);
|
||||
|
||||
if (buckets.length <= 30) {
|
||||
bctx.globalAlpha = 1;
|
||||
bctx.fillStyle = '#888';
|
||||
bctx.font = '8px monospace';
|
||||
bctx.textAlign = 'center';
|
||||
bctx.fillText(buckets[i][0], x + barW / 2, rh - 2);
|
||||
}
|
||||
}
|
||||
bctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
// ── SSE connection ─────────────────────────────────────────────
|
||||
var es = new EventSource('/events');
|
||||
|
||||
es.addEventListener('distribution', function(e) {
|
||||
try {
|
||||
var d = JSON.parse(e.data);
|
||||
updateFromSnapshot(d);
|
||||
} catch(err) { console.error('distribution parse error', err); }
|
||||
});
|
||||
|
||||
es.addEventListener('done', function() {
|
||||
dot.className = 'status-dot done';
|
||||
es.close();
|
||||
});
|
||||
|
||||
es.onerror = function() {
|
||||
dot.className = 'status-dot disconnected';
|
||||
};
|
||||
|
||||
es.onopen = function() {
|
||||
dot.className = 'status-dot';
|
||||
};
|
||||
|
||||
resizeCanvas();
|
||||
drawGraph();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,747 +1 @@
|
|||
pub const DISTRIBUTION_HTML: &str = r##"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Swactor Runtime – Distribution</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Menlo', 'Consolas', 'Monaco', monospace; background: #0f1117; color: #e0e0e0; font-size: 13px; }
|
||||
|
||||
.header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 12px 20px; background: #161822; border-bottom: 1px solid #2a2d3e;
|
||||
}
|
||||
.header-left { display: flex; align-items: center; }
|
||||
.header h1 { font-size: 16px; font-weight: 600; color: #fff; }
|
||||
.status-dot {
|
||||
width: 10px; height: 10px; border-radius: 50%; background: #4caf50;
|
||||
display: inline-block; margin-left: 8px; vertical-align: middle;
|
||||
}
|
||||
.status-dot.disconnected { background: #f44336; }
|
||||
.status-dot.done { background: #ff9800; }
|
||||
|
||||
.nav-links { display: flex; gap: 4px; margin-left: 20px; }
|
||||
.nav-link {
|
||||
color: #888; text-decoration: none; font-size: 12px;
|
||||
padding: 4px 10px; border-radius: 3px; transition: color 0.2s;
|
||||
}
|
||||
.nav-link:hover { color: #e0e0e0; }
|
||||
.nav-link.active { color: #fff; background: #2a2d3e; }
|
||||
|
||||
.header-right { display: flex; align-items: center; gap: 12px; }
|
||||
|
||||
.main {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
height: calc(100vh - 48px);
|
||||
}
|
||||
|
||||
.graph-panel {
|
||||
grid-row: 1 / 3; border-right: 1px solid #2a2d3e; position: relative;
|
||||
min-height: 0;
|
||||
}
|
||||
.graph-panel canvas { width: 100%; height: 100%; display: block; }
|
||||
|
||||
.side-panel { display: flex; flex-direction: column; overflow: hidden; min-height: 0; }
|
||||
|
||||
.stats-panel {
|
||||
flex-shrink: 0; padding: 12px 16px; border-bottom: 1px solid #2a2d3e; background: #161822;
|
||||
}
|
||||
.stats-panel h2 { font-size: 12px; color: #888; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; }
|
||||
|
||||
.stats-cards {
|
||||
display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px;
|
||||
}
|
||||
.stat-card {
|
||||
background: #1c1f2e; border-radius: 4px; padding: 10px; text-align: center;
|
||||
}
|
||||
.stat-card .value { font-size: 22px; font-weight: 700; color: #fff; }
|
||||
.stat-card .label { font-size: 10px; color: #888; text-transform: uppercase; margin-top: 2px; }
|
||||
|
||||
.table-panel {
|
||||
flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden;
|
||||
}
|
||||
.table-panel h2 {
|
||||
font-size: 12px; color: #888; text-transform: uppercase; letter-spacing: 1px;
|
||||
padding: 10px 16px 6px; flex-shrink: 0;
|
||||
}
|
||||
.table-scroll {
|
||||
flex: 1; overflow-y: auto; padding: 0 16px 8px;
|
||||
}
|
||||
.table-scroll table { width: 100%; border-collapse: collapse; }
|
||||
.table-scroll th, .table-scroll td {
|
||||
padding: 3px 8px; text-align: left; border-bottom: 1px solid #1c1f2e; font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.table-scroll th { color: #888; font-weight: 500; position: sticky; top: 0; background: #161822; }
|
||||
|
||||
.state-alive { color: #4caf50; }
|
||||
.state-suspect { color: #ff9800; }
|
||||
.state-dead { color: #f44336; }
|
||||
|
||||
.bottom-panel {
|
||||
grid-column: 1 / -1; border-top: 1px solid #2a2d3e; background: #161822;
|
||||
display: flex; gap: 12px; padding: 12px 16px; height: 200px;
|
||||
}
|
||||
.bottom-section { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||
.bottom-section h2 {
|
||||
font-size: 12px; color: #888; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 6px;
|
||||
}
|
||||
.bottom-section canvas { flex: 1; width: 100%; }
|
||||
.bottom-section .scroll-wrap {
|
||||
flex: 1; overflow-y: auto; font-size: 11px;
|
||||
}
|
||||
|
||||
.ego-hint {
|
||||
position: absolute; bottom: 12px; left: 12px; font-size: 10px;
|
||||
color: #555; pointer-events: none;
|
||||
}
|
||||
|
||||
.node-id-label {
|
||||
position: absolute; top: 12px; left: 12px; font-size: 11px;
|
||||
color: #888; max-width: 50%; overflow: hidden; text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: #0f1117; }
|
||||
::-webkit-scrollbar-thumb { background: #2a2d3e; border-radius: 3px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<h1>
|
||||
Swactor Runtime Dashboard
|
||||
<span id="statusDot" class="status-dot disconnected"></span>
|
||||
</h1>
|
||||
<nav class="nav-links">
|
||||
<a href="/" class="nav-link">Overview</a>
|
||||
<a href="/actors" class="nav-link">Actors</a>
|
||||
<a href="/distribution" class="nav-link active">Distribution</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<span id="nodeLabel" style="color:#888;font-size:12px;">Waiting for data...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
<!-- Left: Graph -->
|
||||
<div class="graph-panel">
|
||||
<canvas id="graphCanvas"></canvas>
|
||||
<div class="ego-hint" id="egoHint">Click a node for ego-centric view. Double-click to reset.</div>
|
||||
<div class="node-id-label" id="selfLabel"></div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Stats + Tables -->
|
||||
<div class="side-panel">
|
||||
<div class="stats-panel">
|
||||
<h2>Distribution Stats</h2>
|
||||
<div class="stats-cards">
|
||||
<div class="stat-card"><div class="value" id="statMembers">0</div><div class="label">Members</div></div>
|
||||
<div class="stat-card"><div class="value" id="statAlive">0</div><div class="label">Alive</div></div>
|
||||
<div class="stat-card"><div class="value" id="statSuspect">0</div><div class="label">Suspect</div></div>
|
||||
<div class="stat-card"><div class="value" id="statDead">0</div><div class="label">Dead</div></div>
|
||||
<div class="stat-card"><div class="value" id="statCache">0</div><div class="label">Cache</div></div>
|
||||
<div class="stat-card"><div class="value" id="statRT">0</div><div class="label">RT Size</div></div>
|
||||
<div class="stat-card"><div class="value" id="statDir">0</div><div class="label">Directory</div></div>
|
||||
<div class="stat-card"><div class="value" id="statRepair">0</div><div class="label">Repair Q</div></div>
|
||||
<div class="stat-card"><div class="value" id="statProbes">0</div><div class="label">Probes</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-panel">
|
||||
<h2>Members <span id="memberCount" style="color:#555;font-weight:400;"></span></h2>
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead><tr><th>State</th><th>Node ID</th><th>Address</th><th>Inc</th></tr></thead>
|
||||
<tbody id="membersBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom: Cache, Gossip Pairs, Routing Histogram -->
|
||||
<div class="bottom-panel">
|
||||
<div class="bottom-section">
|
||||
<h2>LRU Cache <span id="cacheCount" style="color:#555;font-weight:400;"></span></h2>
|
||||
<div class="scroll-wrap">
|
||||
<table style="width:100%;border-collapse:collapse;">
|
||||
<thead><tr><th style="color:#888;font-weight:500;font-size:11px;">Actor</th><th style="color:#888;font-weight:500;font-size:11px;">Node</th></tr></thead>
|
||||
<tbody id="cacheBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bottom-section">
|
||||
<h2>Recent Probes</h2>
|
||||
<div class="scroll-wrap" id="probesWrap"></div>
|
||||
</div>
|
||||
<div class="bottom-section">
|
||||
<h2>Routing Buckets</h2>
|
||||
<canvas id="bucketChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var DASHBOARD_MODE = '__DASHBOARD_MODE__';
|
||||
var dot = document.getElementById('statusDot');
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────
|
||||
var data = null; // latest DistributionNodeSnapshot
|
||||
var selfNodeId = ''; // this node's hex id
|
||||
var focusIdx = -1; // ego-centric focus (-1 = none, 0 = self)
|
||||
|
||||
// Graph state
|
||||
var N = 0;
|
||||
var posX = new Float64Array(0);
|
||||
var posY = new Float64Array(0);
|
||||
var nodeIds = []; // hex strings
|
||||
var nodeStates = []; // 'alive' | 'suspect' | 'dead' | 'self'
|
||||
var nodeAddrs = [];
|
||||
var layoutDone = false;
|
||||
var layoutIter = 0;
|
||||
|
||||
// View transform
|
||||
var vx = 0, vy = 0, vs = 1;
|
||||
var isDragging = false, dragX = 0, dragY = 0, dragVx = 0, dragVy = 0;
|
||||
var clickStartX = 0, clickStartY = 0;
|
||||
|
||||
// Routing neighbor set (for ego highlight)
|
||||
var routingSet = {};
|
||||
|
||||
// ── Quadtree (Barnes-Hut) ──────────────────────────────────────
|
||||
var QF = 11;
|
||||
var qt = new Float64Array(256 * QF);
|
||||
var qtN = 0;
|
||||
var _fx = 0, _fy = 0;
|
||||
|
||||
function qtAlloc(ox, oy, sz) {
|
||||
if (qtN * QF >= qt.length) {
|
||||
var nq = new Float64Array(Math.max(qt.length * 2, 256 * QF));
|
||||
nq.set(qt); qt = nq;
|
||||
}
|
||||
var i = qtN++, o = i * QF;
|
||||
qt[o]=ox; qt[o+1]=oy; qt[o+2]=sz;
|
||||
qt[o+3]=0; qt[o+4]=0; qt[o+5]=0;
|
||||
qt[o+6]=-1; qt[o+7]=-1; qt[o+8]=-1; qt[o+9]=-1;
|
||||
qt[o+10]=-1;
|
||||
return i;
|
||||
}
|
||||
|
||||
function qtBuild() {
|
||||
var x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
||||
for (var i = 0; i < N; i++) {
|
||||
if (posX[i] < x0) x0 = posX[i]; if (posY[i] < y0) y0 = posY[i];
|
||||
if (posX[i] > x1) x1 = posX[i]; if (posY[i] > y1) y1 = posY[i];
|
||||
}
|
||||
var sz = Math.max(x1 - x0, y1 - y0, 1) + 2;
|
||||
qtN = 0;
|
||||
qtAlloc(x0 - 1, y0 - 1, sz);
|
||||
for (var i = 0; i < N; i++) qtIns(0, i, posX[i], posY[i]);
|
||||
}
|
||||
|
||||
function qtIns(ni, bi, bx, by) {
|
||||
var o = ni * QF;
|
||||
if (qt[o+5] === 0) { qt[o+3] = bx; qt[o+4] = by; qt[o+5] = 1; qt[o+10] = bi; return; }
|
||||
if (qt[o+10] >= 0) {
|
||||
if (qt[o+2] < 0.001) { qt[o+5]++; return; }
|
||||
var eb = qt[o+10], ex = qt[o+3], ey = qt[o+4];
|
||||
qt[o+10] = -1;
|
||||
qtInsChild(ni, eb, ex, ey);
|
||||
}
|
||||
var m = qt[o+5];
|
||||
qt[o+3] = (qt[o+3]*m + bx) / (m+1);
|
||||
qt[o+4] = (qt[o+4]*m + by) / (m+1);
|
||||
qt[o+5] = m + 1;
|
||||
qtInsChild(ni, bi, bx, by);
|
||||
}
|
||||
|
||||
function qtInsChild(ni, bi, bx, by) {
|
||||
var o = ni * QF, hs = qt[o+2] / 2;
|
||||
var mx = qt[o] + hs, my = qt[o+1] + hs;
|
||||
var qx = bx < mx ? 0 : 1, qy = by < my ? 0 : 1;
|
||||
var ci = o + 6 + qy * 2 + qx;
|
||||
if (qt[ci] < 0) qt[ci] = qtAlloc(qx ? mx : qt[o], qy ? my : qt[o+1], hs);
|
||||
qtIns(qt[ci], bi, bx, by);
|
||||
}
|
||||
|
||||
function qtCalc(ni, px, py, k2, th2) {
|
||||
if (ni < 0) return;
|
||||
var o = ni * QF;
|
||||
if (qt[o+5] === 0) return;
|
||||
var dx = qt[o+3] - px, dy = qt[o+4] - py;
|
||||
var d2 = dx*dx + dy*dy;
|
||||
if (d2 < 0.0001) d2 = 0.0001;
|
||||
if (qt[o+10] >= 0 || qt[o+2]*qt[o+2]/d2 < th2) {
|
||||
var d = Math.sqrt(d2), f = -(k2 * qt[o+5]) / d2;
|
||||
_fx += (dx/d)*f; _fy += (dy/d)*f;
|
||||
return;
|
||||
}
|
||||
for (var c = 6; c < 10; c++) if (qt[o+c] >= 0) qtCalc(qt[o+c], px, py, k2, th2);
|
||||
}
|
||||
|
||||
// ── Canvas setup ───────────────────────────────────────────────
|
||||
var canvas = document.getElementById('graphCanvas');
|
||||
var ctx = canvas.getContext('2d');
|
||||
|
||||
function resizeCanvas() {
|
||||
var r = canvas.parentElement.getBoundingClientRect();
|
||||
canvas.width = r.width * devicePixelRatio;
|
||||
canvas.height = r.height * devicePixelRatio;
|
||||
canvas.style.width = r.width + 'px';
|
||||
canvas.style.height = r.height + 'px';
|
||||
}
|
||||
window.addEventListener('resize', function() { resizeCanvas(); drawGraph(); });
|
||||
|
||||
// ── Zoom & pan ─────────────────────────────────────────────────
|
||||
canvas.addEventListener('wheel', function(e) {
|
||||
e.preventDefault();
|
||||
var r = canvas.getBoundingClientRect();
|
||||
var mx = e.clientX - r.left, my = e.clientY - r.top;
|
||||
var f = e.deltaY < 0 ? 1.1 : 1 / 1.1;
|
||||
var ns = Math.max(0.05, Math.min(5, vs * f));
|
||||
var ratio = ns / vs;
|
||||
vx = mx - (mx - vx) * ratio;
|
||||
vy = my - (my - vy) * ratio;
|
||||
vs = ns;
|
||||
drawGraph();
|
||||
}, { passive: false });
|
||||
|
||||
canvas.addEventListener('mousedown', function(e) {
|
||||
if (e.button !== 0) return;
|
||||
isDragging = true;
|
||||
dragX = e.clientX; dragY = e.clientY;
|
||||
clickStartX = e.clientX; clickStartY = e.clientY;
|
||||
dragVx = vx; dragVy = vy;
|
||||
canvas.style.cursor = 'grabbing';
|
||||
});
|
||||
window.addEventListener('mousemove', function(e) {
|
||||
if (!isDragging) return;
|
||||
vx = dragVx + (e.clientX - dragX);
|
||||
vy = dragVy + (e.clientY - dragY);
|
||||
drawGraph();
|
||||
});
|
||||
window.addEventListener('mouseup', function(e) {
|
||||
if (isDragging) {
|
||||
var wasDrag = Math.abs(e.clientX - clickStartX) > 3 || Math.abs(e.clientY - clickStartY) > 3;
|
||||
isDragging = false; canvas.style.cursor = '';
|
||||
if (!wasDrag) handleClick(e);
|
||||
}
|
||||
});
|
||||
canvas.addEventListener('dblclick', function() { focusIdx = -1; resetView(); drawGraph(); });
|
||||
|
||||
function resetView() {
|
||||
if (N === 0) { vx = 0; vy = 0; vs = 1; return; }
|
||||
var r = canvas.parentElement.getBoundingClientRect();
|
||||
var w = r.width, h = r.height;
|
||||
var x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
||||
for (var i = 0; i < N; i++) {
|
||||
if (posX[i] < x0) x0 = posX[i]; if (posY[i] < y0) y0 = posY[i];
|
||||
if (posX[i] > x1) x1 = posX[i]; if (posY[i] > y1) y1 = posY[i];
|
||||
}
|
||||
if (!isFinite(x0)) { vx = 0; vy = 0; vs = 1; return; }
|
||||
var pad = 40;
|
||||
vs = Math.min(w / (x1 - x0 + pad * 2), h / (y1 - y0 + pad * 2), 2);
|
||||
vx = (w - (x0 + x1) * vs) / 2;
|
||||
vy = (h - (y0 + y1) * vs) / 2;
|
||||
}
|
||||
|
||||
// ── Click handler (ego-centric selection) ──────────────────────
|
||||
function handleClick(e) {
|
||||
var r = canvas.getBoundingClientRect();
|
||||
var mx = e.clientX - r.left, my = e.clientY - r.top;
|
||||
var wx = (mx - vx) / vs, wy = (my - vy) / vs;
|
||||
var baseR = Math.max(4, Math.min(12, 400 / Math.sqrt(Math.max(N, 1))));
|
||||
var hitR = baseR * 2;
|
||||
var best = -1, bestD = hitR * hitR;
|
||||
for (var i = 0; i < N; i++) {
|
||||
var dx = posX[i] - wx, dy = posY[i] - wy;
|
||||
var d2 = dx*dx + dy*dy;
|
||||
if (d2 < bestD) { bestD = d2; best = i; }
|
||||
}
|
||||
if (best < 0) { focusIdx = -1; }
|
||||
else { focusIdx = best; }
|
||||
drawGraph();
|
||||
}
|
||||
|
||||
// ── Force layout ───────────────────────────────────────────────
|
||||
function initPositions(w, h) {
|
||||
var cx = w / 2, cy = h / 2;
|
||||
var rad = Math.min(cx, cy) * 0.6;
|
||||
for (var i = 0; i < N; i++) {
|
||||
var a = (2 * Math.PI * i) / N - Math.PI / 2;
|
||||
posX[i] = cx + rad * Math.cos(a);
|
||||
posY[i] = cy + rad * Math.sin(a);
|
||||
}
|
||||
}
|
||||
|
||||
function runLayout() {
|
||||
var r = canvas.parentElement.getBoundingClientRect();
|
||||
var w = r.width, h = r.height;
|
||||
if (w === 0 || h === 0) return;
|
||||
var cx = w/2, cy = h/2;
|
||||
var k = Math.sqrt(w*h / Math.max(N, 1)), k2 = k*k;
|
||||
var totalIters = Math.min(200, Math.max(30, Math.floor(20000 / Math.max(N, 1))));
|
||||
var useBH = N > 60, th2 = 0.64;
|
||||
var dx = new Float64Array(N), dy = new Float64Array(N);
|
||||
|
||||
// Edges: self→each member (star topology from this node's perspective)
|
||||
var selfIdx = 0; // node 0 is always 'self'
|
||||
var edges = [];
|
||||
for (var i = 1; i < N; i++) edges.push([selfIdx, i]);
|
||||
|
||||
for (var iter = 0; iter < totalIters; iter++) {
|
||||
dx.fill(0); dy.fill(0);
|
||||
|
||||
if (useBH) {
|
||||
qtBuild();
|
||||
for (var i = 0; i < N; i++) {
|
||||
_fx = 0; _fy = 0;
|
||||
qtCalc(0, posX[i], posY[i], k2, th2);
|
||||
dx[i] += _fx; dy[i] += _fy;
|
||||
}
|
||||
} else {
|
||||
for (var i = 0; i < N; i++) for (var j = i+1; j < N; j++) {
|
||||
var ddx = posX[i]-posX[j], ddy = posY[i]-posY[j];
|
||||
var dist = Math.sqrt(ddx*ddx + ddy*ddy) || 0.01;
|
||||
var f = k2/dist, fx = (ddx/dist)*f, fy = (ddy/dist)*f;
|
||||
dx[i] += fx; dy[i] += fy; dx[j] -= fx; dy[j] -= fy;
|
||||
}
|
||||
}
|
||||
|
||||
for (var e = 0; e < edges.length; e++) {
|
||||
var si = edges[e][0], di = edges[e][1];
|
||||
var ddx = posX[si]-posX[di], ddy = posY[si]-posY[di];
|
||||
var dist = Math.sqrt(ddx*ddx + ddy*ddy) || 0.01;
|
||||
var f = (dist*dist)/k, fx = (ddx/dist)*f, fy = (ddy/dist)*f;
|
||||
dx[si] -= fx; dy[si] -= fy; dx[di] += fx; dy[di] += fy;
|
||||
}
|
||||
|
||||
for (var i = 0; i < N; i++) {
|
||||
dx[i] -= (posX[i]-cx)*0.01; dy[i] -= (posY[i]-cy)*0.01;
|
||||
}
|
||||
|
||||
var temp = Math.max(0.1, 1 - iter/totalIters);
|
||||
for (var i = 0; i < N; i++) {
|
||||
var dist = Math.sqrt(dx[i]*dx[i] + dy[i]*dy[i]) || 0.01;
|
||||
var cap = Math.min(dist, 10*temp);
|
||||
posX[i] += (dx[i]/dist)*cap; posY[i] += (dy[i]/dist)*cap;
|
||||
posX[i] = Math.max(40, Math.min(w-40, posX[i]));
|
||||
posY[i] = Math.max(40, Math.min(h-40, posY[i]));
|
||||
}
|
||||
}
|
||||
|
||||
layoutDone = true;
|
||||
resetView();
|
||||
}
|
||||
|
||||
// ── Draw graph ─────────────────────────────────────────────────
|
||||
var stateColors = { 'alive': '#4caf50', 'suspect': '#ff9800', 'dead': '#f44336', 'self': '#6366f1' };
|
||||
|
||||
function drawGraph() {
|
||||
var dpr = devicePixelRatio || 1;
|
||||
var r = canvas.parentElement.getBoundingClientRect();
|
||||
if (canvas.width !== Math.round(r.width * dpr)) resizeCanvas();
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, r.width, r.height);
|
||||
|
||||
if (N === 0) {
|
||||
ctx.fillStyle = '#555';
|
||||
ctx.font = '13px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('No distribution data yet', r.width / 2, r.height / 2);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(vx, vy);
|
||||
ctx.scale(vs, vs);
|
||||
|
||||
var baseR = Math.max(4, Math.min(12, 400 / Math.sqrt(Math.max(N, 1))));
|
||||
|
||||
// Determine highlighted set for ego mode
|
||||
var highlighted = null;
|
||||
if (focusIdx >= 0) {
|
||||
highlighted = {};
|
||||
highlighted[focusIdx] = true;
|
||||
// If focus is self (0), highlight all connected members
|
||||
// If focus is a member, highlight self and that member
|
||||
if (focusIdx === 0) {
|
||||
for (var i = 1; i < N; i++) highlighted[i] = true;
|
||||
} else {
|
||||
highlighted[0] = true; // always show self
|
||||
}
|
||||
}
|
||||
|
||||
// Draw edges (self → each member)
|
||||
for (var i = 1; i < N; i++) {
|
||||
var alpha = 1;
|
||||
var dash = false;
|
||||
if (highlighted) {
|
||||
if (!highlighted[i]) { alpha = 0.08; }
|
||||
else {
|
||||
// Check if this node is a routing neighbor
|
||||
var nid = nodeIds[i];
|
||||
if (routingSet[nid]) { dash = true; alpha = 0.7; }
|
||||
else { alpha = 0.5; }
|
||||
}
|
||||
} else {
|
||||
alpha = 0.2;
|
||||
}
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(posX[0], posY[0]);
|
||||
ctx.lineTo(posX[i], posY[i]);
|
||||
ctx.strokeStyle = 'rgba(99,102,241,' + alpha + ')';
|
||||
ctx.lineWidth = 1 / vs;
|
||||
if (dash) { ctx.setLineDash([4/vs, 4/vs]); }
|
||||
else { ctx.setLineDash([]); }
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Draw nodes
|
||||
for (var i = 0; i < N; i++) {
|
||||
var opacity = 1;
|
||||
if (highlighted && !highlighted[i]) opacity = 0.15;
|
||||
|
||||
var col = stateColors[nodeStates[i]] || '#888';
|
||||
var isSelf = (nodeStates[i] === 'self');
|
||||
|
||||
ctx.beginPath();
|
||||
var nr = isSelf ? baseR * 1.5 : baseR;
|
||||
ctx.arc(posX[i], posY[i], nr, 0, Math.PI * 2);
|
||||
ctx.globalAlpha = opacity * 0.85;
|
||||
ctx.fillStyle = col;
|
||||
ctx.fill();
|
||||
|
||||
if (isSelf) {
|
||||
ctx.lineWidth = 2 / vs;
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.globalAlpha = opacity * 0.6;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
if (focusIdx === i) {
|
||||
ctx.lineWidth = 2 / vs;
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.globalAlpha = opacity;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// Label (short ID)
|
||||
if (vs > 0.5) {
|
||||
ctx.fillStyle = '#ccc';
|
||||
ctx.globalAlpha = opacity;
|
||||
ctx.font = Math.round(9 / vs) + 'px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
var label = nodeIds[i] ? nodeIds[i].substring(0, 8) : '';
|
||||
ctx.fillText(label, posX[i], posY[i] - nr - 3/vs);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// ── Update from snapshot ───────────────────────────────────────
|
||||
function updateFromSnapshot(d) {
|
||||
data = d;
|
||||
|
||||
// Build node arrays: index 0 = self, then members
|
||||
var oldN = N;
|
||||
var newIds = [d.node_id];
|
||||
var newStates = ['self'];
|
||||
var newAddrs = [d.listen_addr];
|
||||
for (var i = 0; i < d.members.length; i++) {
|
||||
newIds.push(d.members[i].node_id);
|
||||
newStates.push(d.members[i].state);
|
||||
newAddrs.push(d.members[i].addr);
|
||||
}
|
||||
|
||||
// Build routing neighbor set
|
||||
routingSet = {};
|
||||
for (var i = 0; i < d.routing_neighbors.length; i++) {
|
||||
routingSet[d.routing_neighbors[i].node_id] = true;
|
||||
}
|
||||
|
||||
selfNodeId = d.node_id;
|
||||
|
||||
// Check if topology changed
|
||||
var changed = newIds.length !== N;
|
||||
if (!changed) {
|
||||
for (var i = 0; i < newIds.length; i++) {
|
||||
if (newIds[i] !== nodeIds[i]) { changed = true; break; }
|
||||
}
|
||||
}
|
||||
|
||||
nodeIds = newIds;
|
||||
nodeStates = newStates;
|
||||
nodeAddrs = newAddrs;
|
||||
N = nodeIds.length;
|
||||
|
||||
if (changed || !layoutDone) {
|
||||
posX = new Float64Array(N);
|
||||
posY = new Float64Array(N);
|
||||
resizeCanvas();
|
||||
initPositions(canvas.parentElement.getBoundingClientRect().width,
|
||||
canvas.parentElement.getBoundingClientRect().height);
|
||||
runLayout();
|
||||
} else {
|
||||
// Just update states, redraw
|
||||
drawGraph();
|
||||
}
|
||||
|
||||
updateUI(d);
|
||||
}
|
||||
|
||||
function updateUI(d) {
|
||||
// Self label
|
||||
document.getElementById('selfLabel').textContent = 'Node: ' + d.node_id.substring(0, 16) + '\u2026';
|
||||
document.getElementById('nodeLabel').textContent = d.listen_addr;
|
||||
|
||||
// Stats cards
|
||||
document.getElementById('statMembers').textContent = d.members.length;
|
||||
document.getElementById('statAlive').textContent = d.alive_count;
|
||||
document.getElementById('statSuspect').textContent = d.suspect_count;
|
||||
document.getElementById('statDead').textContent = d.dead_count;
|
||||
document.getElementById('statCache').textContent = d.cache_size;
|
||||
document.getElementById('statRT').textContent = d.routing_table_size;
|
||||
document.getElementById('statDir').textContent = d.directory_entry_count;
|
||||
document.getElementById('statRepair').textContent = d.repair_queue_size;
|
||||
document.getElementById('statProbes').textContent = d.recent_probe_targets.length;
|
||||
|
||||
// Color alive/suspect/dead
|
||||
document.getElementById('statAlive').style.color = '#4caf50';
|
||||
document.getElementById('statSuspect').style.color = d.suspect_count > 0 ? '#ff9800' : '#fff';
|
||||
document.getElementById('statDead').style.color = d.dead_count > 0 ? '#f44336' : '#fff';
|
||||
|
||||
// Members table
|
||||
var body = document.getElementById('membersBody');
|
||||
body.innerHTML = '';
|
||||
document.getElementById('memberCount').textContent = '(' + d.members.length + ')';
|
||||
for (var i = 0; i < d.members.length; i++) {
|
||||
var m = d.members[i];
|
||||
var cls = 'state-' + m.state;
|
||||
var tr = document.createElement('tr');
|
||||
tr.innerHTML =
|
||||
'<td class="' + cls + '">' + m.state + '</td>' +
|
||||
'<td style="color:#aaa;font-size:10px;">' + m.node_id.substring(0, 16) + '\u2026</td>' +
|
||||
'<td>' + m.addr + '</td>' +
|
||||
'<td>' + m.incarnation + '</td>';
|
||||
body.appendChild(tr);
|
||||
}
|
||||
|
||||
// Cache table
|
||||
var cacheBody = document.getElementById('cacheBody');
|
||||
cacheBody.innerHTML = '';
|
||||
document.getElementById('cacheCount').textContent = '(' + d.cache_entries.length + ')';
|
||||
var cacheMax = Math.min(d.cache_entries.length, 200);
|
||||
for (var i = 0; i < cacheMax; i++) {
|
||||
var e = d.cache_entries[i];
|
||||
var tr = document.createElement('tr');
|
||||
tr.innerHTML =
|
||||
'<td style="color:#aaa;font-size:10px;max-width:120px;overflow:hidden;text-overflow:ellipsis;">' + e.actor_addr + '</td>' +
|
||||
'<td style="color:#aaa;font-size:10px;">' + e.node_id.substring(0, 12) + '\u2026</td>';
|
||||
cacheBody.appendChild(tr);
|
||||
}
|
||||
|
||||
// Recent probes
|
||||
var probesWrap = document.getElementById('probesWrap');
|
||||
probesWrap.innerHTML = '';
|
||||
for (var i = d.recent_probe_targets.length - 1; i >= 0; i--) {
|
||||
var div = document.createElement('div');
|
||||
div.style.cssText = 'padding:2px 0;color:#aaa;font-size:11px;border-bottom:1px solid #1c1f2e;';
|
||||
div.textContent = d.recent_probe_targets[i].substring(0, 16) + '\u2026';
|
||||
probesWrap.appendChild(div);
|
||||
}
|
||||
|
||||
// Routing bucket histogram
|
||||
drawBucketChart(d.routing_buckets);
|
||||
}
|
||||
|
||||
// ── Bucket histogram ───────────────────────────────────────────
|
||||
function drawBucketChart(buckets) {
|
||||
var cv = document.getElementById('bucketChart');
|
||||
var bctx = cv.getContext('2d');
|
||||
var dpr = devicePixelRatio || 1;
|
||||
var rect = cv.parentElement.getBoundingClientRect();
|
||||
var rw = rect.width, rh = cv.parentElement.clientHeight - 20;
|
||||
cv.width = rw * dpr;
|
||||
cv.height = rh * dpr;
|
||||
cv.style.width = rw + 'px';
|
||||
cv.style.height = rh + 'px';
|
||||
bctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
bctx.clearRect(0, 0, rw, rh);
|
||||
|
||||
if (!buckets || buckets.length === 0) {
|
||||
bctx.fillStyle = '#555';
|
||||
bctx.font = '11px monospace';
|
||||
bctx.textAlign = 'center';
|
||||
bctx.fillText('No buckets', rw / 2, rh / 2);
|
||||
return;
|
||||
}
|
||||
|
||||
var maxCount = 1;
|
||||
for (var i = 0; i < buckets.length; i++) {
|
||||
if (buckets[i][1] > maxCount) maxCount = buckets[i][1];
|
||||
}
|
||||
|
||||
var barW = Math.max(4, Math.floor((rw - 20) / buckets.length) - 2);
|
||||
var chartH = rh - 20;
|
||||
|
||||
for (var i = 0; i < buckets.length; i++) {
|
||||
var x = 10 + i * (barW + 2);
|
||||
var h = (buckets[i][1] / maxCount) * (chartH - 4);
|
||||
bctx.fillStyle = '#6366f1';
|
||||
bctx.globalAlpha = 0.8;
|
||||
bctx.fillRect(x, chartH - h, barW, h);
|
||||
|
||||
if (buckets.length <= 30) {
|
||||
bctx.globalAlpha = 1;
|
||||
bctx.fillStyle = '#888';
|
||||
bctx.font = '8px monospace';
|
||||
bctx.textAlign = 'center';
|
||||
bctx.fillText(buckets[i][0], x + barW / 2, rh - 2);
|
||||
}
|
||||
}
|
||||
bctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
// ── SSE connection ─────────────────────────────────────────────
|
||||
var es = new EventSource('/events');
|
||||
|
||||
es.addEventListener('distribution', function(e) {
|
||||
try {
|
||||
var d = JSON.parse(e.data);
|
||||
updateFromSnapshot(d);
|
||||
} catch(err) { console.error('distribution parse error', err); }
|
||||
});
|
||||
|
||||
es.addEventListener('done', function() {
|
||||
dot.className = 'status-dot done';
|
||||
es.close();
|
||||
});
|
||||
|
||||
es.onerror = function() {
|
||||
dot.className = 'status-dot disconnected';
|
||||
};
|
||||
|
||||
es.onopen = function() {
|
||||
dot.className = 'status-dot';
|
||||
};
|
||||
|
||||
resizeCanvas();
|
||||
drawGraph();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"##;
|
||||
pub const DISTRIBUTION_HTML: &str = include_str!("distribution.html");
|
||||
|
|
|
|||
293
crates/runtime-dashboard/tests/dashboard_core.rs
Normal file
293
crates/runtime-dashboard/tests/dashboard_core.rs
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use runtime_dashboard::collector::StatsCollector;
|
||||
use runtime_dashboard::layer::{DashboardEvent, EventStore};
|
||||
use runtime_dashboard::trace::RuntimeTrace;
|
||||
use swactor::actor::ActorAddress;
|
||||
use swactor::stats::{ActorSnapshot, StatsHook};
|
||||
|
||||
fn make_event(message: &str) -> DashboardEvent {
|
||||
DashboardEvent {
|
||||
seq: 0, // filled by EventStore::push
|
||||
timestamp_ms: 1000,
|
||||
level: "INFO".into(),
|
||||
message: message.into(),
|
||||
worker_id: None,
|
||||
fields: serde_json::Map::new(),
|
||||
}
|
||||
}
|
||||
|
||||
// ── EventStore: Streaming cursor semantics ──────────────────────────────
|
||||
|
||||
/// Scenario: Two clients consume the same event stream at different rates.
|
||||
/// A fast client reads every event; a slow client joins late and catches up.
|
||||
/// Both eventually see the same final event.
|
||||
#[test]
|
||||
fn two_clients_consuming_at_different_rates() {
|
||||
let store = EventStore::new(100, false, 0);
|
||||
|
||||
// Fast client starts at cursor 0
|
||||
let mut fast_cursor: u64 = 0;
|
||||
|
||||
// Push 5 events
|
||||
for i in 0..5 {
|
||||
store.push(make_event(&format!("event-{i}")));
|
||||
}
|
||||
|
||||
// Fast client reads all 5
|
||||
let (batch, new_cursor) = store.read_from(fast_cursor);
|
||||
assert_eq!(batch.len(), 5);
|
||||
assert_eq!(batch[0].message, "event-0");
|
||||
assert_eq!(batch[4].message, "event-4");
|
||||
fast_cursor = new_cursor;
|
||||
|
||||
// Push 3 more
|
||||
for i in 5..8 {
|
||||
store.push(make_event(&format!("event-{i}")));
|
||||
}
|
||||
|
||||
// Fast client sees only new 3
|
||||
let (batch, new_cursor) = store.read_from(fast_cursor);
|
||||
assert_eq!(batch.len(), 3);
|
||||
assert_eq!(batch[0].message, "event-5");
|
||||
fast_cursor = new_cursor;
|
||||
|
||||
// Slow client joins now at cursor 0 — sees all 8
|
||||
let (slow_batch, slow_cursor) = store.read_from(0);
|
||||
assert_eq!(slow_batch.len(), 8);
|
||||
assert_eq!(slow_batch[7].message, "event-7");
|
||||
|
||||
// Both cursors now agree
|
||||
assert_eq!(fast_cursor, slow_cursor);
|
||||
}
|
||||
|
||||
/// Scenario: The event stream overflows the ring buffer.
|
||||
/// A client that fell behind loses old events but gets the most recent window.
|
||||
#[test]
|
||||
fn ring_buffer_overflow_caps_old_cursors() {
|
||||
let store = EventStore::new(10, false, 0);
|
||||
|
||||
// Push 25 events into a 10-capacity ring
|
||||
for i in 0..25 {
|
||||
store.push(make_event(&format!("event-{i}")));
|
||||
}
|
||||
|
||||
// A client at cursor 0 gets only the most recent 10
|
||||
let (batch, cursor) = store.read_from(0);
|
||||
assert_eq!(batch.len(), 10);
|
||||
assert_eq!(batch[0].message, "event-15");
|
||||
assert_eq!(batch[9].message, "event-24");
|
||||
assert_eq!(cursor, 25);
|
||||
|
||||
// A client already caught up gets nothing
|
||||
let (batch, _) = store.read_from(cursor);
|
||||
assert!(batch.is_empty());
|
||||
}
|
||||
|
||||
/// Scenario: Client has a cursor beyond the latest event (future cursor).
|
||||
/// This can happen if events were trimmed. The client should get nothing, not panic.
|
||||
#[test]
|
||||
fn future_cursor_returns_empty() {
|
||||
let store = EventStore::new(10, false, 0);
|
||||
store.push(make_event("only-one"));
|
||||
|
||||
let (batch, cursor) = store.read_from(999);
|
||||
assert!(batch.is_empty());
|
||||
assert_eq!(cursor, 999); // cursor unchanged
|
||||
}
|
||||
|
||||
/// Scenario: Empty store — no events ever pushed.
|
||||
#[test]
|
||||
fn empty_store_returns_nothing() {
|
||||
let store = EventStore::new(10, false, 0);
|
||||
let (batch, cursor) = store.read_from(0);
|
||||
assert!(batch.is_empty());
|
||||
assert_eq!(cursor, 0);
|
||||
}
|
||||
|
||||
// ── EventStore: Recording pipeline ──────────────────────────────────────
|
||||
|
||||
/// Scenario: A monitoring session records events and saves a valid trace file.
|
||||
/// Given: recording enabled, events flowing through the store
|
||||
/// When: all_events() is called
|
||||
/// Then: every event is available and the data round-trips through JSON
|
||||
#[test]
|
||||
fn recording_session_produces_replayable_trace() {
|
||||
let store = EventStore::new(5, true, 100);
|
||||
|
||||
// Simulate a burst of runtime events
|
||||
for i in 0..20 {
|
||||
let mut ev = make_event(&format!("tick-{i}"));
|
||||
ev.worker_id = Some(i % 3);
|
||||
store.push(ev);
|
||||
}
|
||||
|
||||
// Drain the recording log
|
||||
let events = store.all_events().expect("recording should be enabled");
|
||||
assert_eq!(events.len(), 20, "all 20 events should be in the recording");
|
||||
|
||||
// Build a trace and round-trip through JSON
|
||||
let trace = RuntimeTrace {
|
||||
events,
|
||||
stats_timeline: Vec::new(),
|
||||
};
|
||||
let json = serde_json::to_string(&trace).unwrap();
|
||||
let restored: RuntimeTrace = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(restored.events.len(), 20);
|
||||
assert_eq!(restored.events[0].message, "tick-0");
|
||||
assert_eq!(restored.events[19].message, "tick-19");
|
||||
assert_eq!(restored.events[1].worker_id, Some(1));
|
||||
}
|
||||
|
||||
/// Scenario: Recording disabled — all_events returns None.
|
||||
#[test]
|
||||
fn no_recording_means_no_full_log() {
|
||||
let store = EventStore::new(10, false, 0);
|
||||
store.push(make_event("hello"));
|
||||
assert!(store.all_events().is_none());
|
||||
}
|
||||
|
||||
/// Scenario: all_events() is destructive — second call gets an empty vec.
|
||||
#[test]
|
||||
fn recording_drain_is_destructive() {
|
||||
let store = EventStore::new(5, true, 100);
|
||||
store.push(make_event("one"));
|
||||
store.push(make_event("two"));
|
||||
|
||||
let first = store.all_events().unwrap();
|
||||
assert_eq!(first.len(), 2);
|
||||
|
||||
let second = store.all_events().unwrap();
|
||||
assert!(second.is_empty(), "second drain should get nothing");
|
||||
}
|
||||
|
||||
// ── StatsCollector: Multi-worker snapshot aggregation ───────────────────
|
||||
|
||||
/// Scenario: Three workers each report actor snapshots independently.
|
||||
/// The dashboard merges all workers' data into a single view.
|
||||
#[test]
|
||||
fn three_workers_report_independently_merged_view_is_complete() {
|
||||
let collector = StatsCollector::new(3);
|
||||
|
||||
let addr_a = ActorAddress::new_random();
|
||||
let addr_b = ActorAddress::new_random();
|
||||
let addr_c = ActorAddress::new_random();
|
||||
|
||||
// Worker 0 reports 1 actor
|
||||
collector.on_tick(0, &[ActorSnapshot {
|
||||
address: addr_a,
|
||||
mailbox_depth: 5,
|
||||
last_msg_type: Some("Ping"),
|
||||
messages_processed: 100,
|
||||
poisoned: false,
|
||||
}]);
|
||||
|
||||
// Worker 1 reports 1 actor
|
||||
collector.on_tick(1, &[ActorSnapshot {
|
||||
address: addr_b,
|
||||
mailbox_depth: 0,
|
||||
last_msg_type: None,
|
||||
messages_processed: 50,
|
||||
poisoned: false,
|
||||
}]);
|
||||
|
||||
// Worker 2 reports 1 actor (poisoned)
|
||||
collector.on_tick(2, &[ActorSnapshot {
|
||||
address: addr_c,
|
||||
mailbox_depth: 3,
|
||||
last_msg_type: Some("BadMsg"),
|
||||
messages_processed: 10,
|
||||
poisoned: true,
|
||||
}]);
|
||||
|
||||
// Dashboard reads merged view
|
||||
let details = collector.actor_details();
|
||||
assert_eq!(details.len(), 3, "all 3 actors from 3 workers");
|
||||
|
||||
let info_a = details.iter().find(|d| d.address == addr_a).unwrap();
|
||||
assert_eq!(info_a.worker_id, 0);
|
||||
assert_eq!(info_a.mailbox_depth, 5);
|
||||
assert_eq!(info_a.messages_processed, 100);
|
||||
|
||||
let info_c = details.iter().find(|d| d.address == addr_c).unwrap();
|
||||
assert!(info_c.poisoned);
|
||||
assert_eq!(info_c.worker_id, 2);
|
||||
}
|
||||
|
||||
/// Scenario: A worker updates its snapshots — old data is replaced, not accumulated.
|
||||
#[test]
|
||||
fn worker_update_replaces_stale_snapshot() {
|
||||
let collector = StatsCollector::new(1);
|
||||
let addr = ActorAddress::new_random();
|
||||
|
||||
// First tick: 1 actor with 10 messages
|
||||
collector.on_tick(0, &[ActorSnapshot {
|
||||
address: addr,
|
||||
mailbox_depth: 5,
|
||||
last_msg_type: None,
|
||||
messages_processed: 10,
|
||||
poisoned: false,
|
||||
}]);
|
||||
|
||||
assert_eq!(collector.actor_details().len(), 1);
|
||||
assert_eq!(collector.actor_details()[0].messages_processed, 10);
|
||||
|
||||
// Second tick: same actor now has 25 messages
|
||||
collector.on_tick(0, &[ActorSnapshot {
|
||||
address: addr,
|
||||
mailbox_depth: 2,
|
||||
last_msg_type: Some("Update"),
|
||||
messages_processed: 25,
|
||||
poisoned: false,
|
||||
}]);
|
||||
|
||||
let details = collector.actor_details();
|
||||
assert_eq!(details.len(), 1, "still 1 actor, not 2");
|
||||
assert_eq!(details[0].messages_processed, 25);
|
||||
assert_eq!(details[0].mailbox_depth, 2);
|
||||
}
|
||||
|
||||
/// Scenario: A worker reports zero actors (all stopped). Dashboard reflects empty.
|
||||
#[test]
|
||||
fn worker_reports_empty_after_all_actors_stop() {
|
||||
let collector = StatsCollector::new(2);
|
||||
let addr = ActorAddress::new_random();
|
||||
|
||||
// Worker 0 has actors
|
||||
collector.on_tick(0, &[ActorSnapshot {
|
||||
address: addr,
|
||||
mailbox_depth: 0,
|
||||
last_msg_type: None,
|
||||
messages_processed: 5,
|
||||
poisoned: false,
|
||||
}]);
|
||||
assert_eq!(collector.actor_details().len(), 1);
|
||||
|
||||
// Worker 0 reports empty (all actors stopped)
|
||||
collector.on_tick(0, &[]);
|
||||
assert!(collector.actor_details().is_empty());
|
||||
}
|
||||
|
||||
// ── Integration: EventStore sequences are monotonically increasing ──────
|
||||
|
||||
/// Scenario: Push events from "multiple sources" — sequences never have gaps or duplicates.
|
||||
#[test]
|
||||
fn event_sequences_are_gap_free_and_monotonic() {
|
||||
let store = Arc::new(EventStore::new(50, false, 0));
|
||||
|
||||
// Simulate interleaved pushes
|
||||
for i in 0..30 {
|
||||
let mut ev = make_event(&format!("source-{}-event", i % 3));
|
||||
ev.worker_id = Some(i % 3);
|
||||
store.push(ev);
|
||||
}
|
||||
|
||||
let (batch, _) = store.read_from(0);
|
||||
assert_eq!(batch.len(), 30);
|
||||
|
||||
// Verify monotonic sequences with no gaps
|
||||
for (i, ev) in batch.iter().enumerate() {
|
||||
assert_eq!(ev.seq, i as u64, "seq should be monotonically increasing");
|
||||
}
|
||||
}
|
||||
1715
crates/simulation-dashboard/src/dashboard.html
Normal file
1715
crates/simulation-dashboard/src/dashboard.html
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -2,6 +2,7 @@ use swactor::actor::{ActorAddress, ActorInterface, Ctx, Message, MonitorRef};
|
|||
use swactor::Error;
|
||||
|
||||
use crate::StdExtension;
|
||||
use crate::timer_wheel::{CloneMsg, TimerRequest};
|
||||
|
||||
fn get_ext<'a>(ctx: &'a Ctx) -> &'a StdExtension {
|
||||
ctx.extension()
|
||||
|
|
@ -68,6 +69,71 @@ impl CtxNaming for Ctx<'_> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Watching extension for [`Ctx`].
|
||||
///
|
||||
/// Provides `watch` / `unwatch` via the [`StdExtension`] watch registry.
|
||||
/// When a watched actor dies, the watcher receives an [`ActorExited`] message
|
||||
/// delivered to its `on_actor_exit()` callback.
|
||||
pub trait CtxWatching {
|
||||
/// Watch another actor's liveness. If the target dies, this actor
|
||||
/// receives an `ActorExited` message.
|
||||
///
|
||||
/// Calling watch() multiple times on the same target is idempotent —
|
||||
/// only one notification is delivered.
|
||||
fn watch(&self, target: ActorAddress);
|
||||
|
||||
/// Stop watching an actor. No notification will be delivered if the
|
||||
/// target subsequently dies.
|
||||
fn unwatch(&self, target: ActorAddress);
|
||||
}
|
||||
|
||||
impl CtxWatching for Ctx<'_> {
|
||||
fn watch(&self, target: ActorAddress) {
|
||||
get_ext(self).watch_registry.watch(self.self_addr(), target);
|
||||
}
|
||||
|
||||
fn unwatch(&self, target: ActorAddress) {
|
||||
get_ext(self).watch_registry.unwatch(self.self_addr(), target);
|
||||
}
|
||||
}
|
||||
|
||||
/// Timer extension for [`Ctx`].
|
||||
///
|
||||
/// Provides `send_after_ticks` / `send_interval_ticks` via the per-worker
|
||||
/// [`TimerWheel`](crate::timer_wheel::TimerWheel).
|
||||
pub trait CtxTimers {
|
||||
/// Schedule a one-shot timer: deliver `msg` to `addr` after `ticks` worker ticks.
|
||||
///
|
||||
/// The message is delivered as a normal mailbox message during the fire tick,
|
||||
/// before `tick_all` processes messages. The timer is tick-counted (deterministic),
|
||||
/// not wall-clock based.
|
||||
fn send_after_ticks<M: Message>(&self, addr: ActorAddress, msg: M, ticks: u64);
|
||||
|
||||
/// Schedule a repeating timer: deliver a clone of `msg` to `addr` every `period` ticks.
|
||||
///
|
||||
/// The first delivery happens after `period` ticks. The message is cloned for each
|
||||
/// delivery. The timer continues until the target actor is stopped/poisoned.
|
||||
fn send_interval_ticks<M: Message>(&self, addr: ActorAddress, msg: M, period: u64);
|
||||
}
|
||||
|
||||
impl CtxTimers for Ctx<'_> {
|
||||
fn send_after_ticks<M: Message>(&self, addr: ActorAddress, msg: M, ticks: u64) {
|
||||
self.raw_inner().post_worker_request(Box::new(TimerRequest::Once {
|
||||
dest: addr,
|
||||
msg: Box::new(msg),
|
||||
ticks,
|
||||
}));
|
||||
}
|
||||
|
||||
fn send_interval_ticks<M: Message>(&self, addr: ActorAddress, msg: M, period: u64) {
|
||||
self.raw_inner().post_worker_request(Box::new(TimerRequest::Interval {
|
||||
dest: addr,
|
||||
msg: Box::new(msg) as Box<dyn CloneMsg>,
|
||||
period,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Group extension for [`Ctx`].
|
||||
///
|
||||
/// Provides `join_group`, `leave_group`, `publish`, and `group_members` via
|
||||
|
|
|
|||
|
|
@ -1,18 +1,21 @@
|
|||
use std::any::Any;
|
||||
|
||||
use swactor::actor::{ActorAddress, Down, StopReason};
|
||||
use swactor::extension::RuntimeExtension;
|
||||
use swactor::actor::{ActorAddress, Down, ExitReason, StopReason};
|
||||
use swactor::extension::{RuntimeExtension, WorkerExtension};
|
||||
|
||||
use crate::group_registry::GroupRegistry;
|
||||
use crate::monitor_registry::MonitorRegistry;
|
||||
use crate::name_registry::NameRegistry;
|
||||
use crate::timer_wheel::TimerWheel;
|
||||
use crate::watch_registry::WatchRegistry;
|
||||
|
||||
/// Standard library extension — provides naming, monitoring, and group registries.
|
||||
/// Standard library extension — provides naming, monitoring, watching, and group registries.
|
||||
///
|
||||
/// Install on a `Runtime` via `runtime.with_extension(Arc::new(StdExtension::new()))`.
|
||||
pub struct StdExtension {
|
||||
pub(crate) name_registry: NameRegistry,
|
||||
pub(crate) monitor_registry: MonitorRegistry,
|
||||
pub(crate) watch_registry: WatchRegistry,
|
||||
pub(crate) group_registry: GroupRegistry,
|
||||
}
|
||||
|
||||
|
|
@ -21,6 +24,7 @@ impl StdExtension {
|
|||
Self {
|
||||
name_registry: NameRegistry::new(),
|
||||
monitor_registry: MonitorRegistry::new(),
|
||||
watch_registry: WatchRegistry::new(),
|
||||
group_registry: GroupRegistry::new(),
|
||||
}
|
||||
}
|
||||
|
|
@ -32,19 +36,36 @@ impl Default for StdExtension {
|
|||
}
|
||||
}
|
||||
|
||||
/// Map StopReason → ExitReason for watch notifications.
|
||||
fn stop_to_exit(reason: StopReason) -> ExitReason {
|
||||
match reason {
|
||||
StopReason::Normal => ExitReason::Stopped,
|
||||
StopReason::Panicked => ExitReason::Panicked,
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimeExtension for StdExtension {
|
||||
fn on_actor_death(
|
||||
&self,
|
||||
dead: &[(ActorAddress, StopReason)],
|
||||
) -> Vec<(ActorAddress, Box<dyn Any + Send>)> {
|
||||
let mut notifications = Vec::new();
|
||||
|
||||
for &(addr, reason) in dead {
|
||||
// Monitor notifications (Down)
|
||||
let watchers = self.monitor_registry.take_monitors(&addr);
|
||||
for (_mref, watcher) in watchers {
|
||||
let down = Down { addr, reason };
|
||||
notifications.push((watcher, Box::new(down) as Box<dyn Any + Send>));
|
||||
}
|
||||
|
||||
// Watch notifications (ActorExited)
|
||||
let watch_notifications = self.watch_registry.notify_death(addr, stop_to_exit(reason));
|
||||
for (watcher, exited) in watch_notifications {
|
||||
notifications.push((watcher, Box::new(exited) as Box<dyn Any + Send>));
|
||||
}
|
||||
}
|
||||
|
||||
notifications
|
||||
}
|
||||
|
||||
|
|
@ -53,10 +74,15 @@ impl RuntimeExtension for StdExtension {
|
|||
self.name_registry.unregister_by_addr(addr);
|
||||
self.group_registry.cleanup(addr);
|
||||
self.monitor_registry.remove_watcher(addr);
|
||||
self.watch_registry.cleanup_watcher(addr);
|
||||
}
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn create_worker_extension(&self) -> Option<Box<dyn WorkerExtension>> {
|
||||
Some(Box::new(TimerWheel::new()))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@ mod supervisor;
|
|||
mod router;
|
||||
pub mod name_registry;
|
||||
pub mod monitor_registry;
|
||||
pub mod watch_registry;
|
||||
pub mod group_registry;
|
||||
pub(crate) mod timer_wheel;
|
||||
mod extension;
|
||||
mod ctx_ext;
|
||||
mod runtime_ext;
|
||||
|
|
@ -10,5 +12,5 @@ mod runtime_ext;
|
|||
pub use supervisor::{ChildSpec, RestartPolicy, Supervisor, SupervisorStrategy};
|
||||
pub use router::{Router, RoutingStrategy};
|
||||
pub use extension::StdExtension;
|
||||
pub use ctx_ext::{CtxMonitoring, CtxNaming, CtxGroups};
|
||||
pub use runtime_ext::{RuntimeNaming, RuntimeGroups};
|
||||
pub use ctx_ext::{CtxMonitoring, CtxNaming, CtxGroups, CtxWatching, CtxTimers};
|
||||
pub use runtime_ext::{RuntimeNaming, RuntimeGroups, RuntimeWatching};
|
||||
|
|
|
|||
|
|
@ -61,6 +61,27 @@ impl RuntimeNaming for Runtime {
|
|||
}
|
||||
}
|
||||
|
||||
/// Watching extension for [`Runtime`].
|
||||
///
|
||||
/// Provides `watch` / `unwatch` via the [`StdExtension`] watch registry.
|
||||
pub trait RuntimeWatching {
|
||||
/// Register a watch: `watcher` receives `ActorExited` when `target` dies.
|
||||
fn watch(&self, watcher: ActorAddress, target: ActorAddress);
|
||||
|
||||
/// Cancel a watch.
|
||||
fn unwatch(&self, watcher: ActorAddress, target: ActorAddress);
|
||||
}
|
||||
|
||||
impl RuntimeWatching for Runtime {
|
||||
fn watch(&self, watcher: ActorAddress, target: ActorAddress) {
|
||||
get_ext(self).watch_registry.watch(watcher, target);
|
||||
}
|
||||
|
||||
fn unwatch(&self, watcher: ActorAddress, target: ActorAddress) {
|
||||
get_ext(self).watch_registry.unwatch(watcher, target);
|
||||
}
|
||||
}
|
||||
|
||||
/// Group extension for [`Runtime`].
|
||||
///
|
||||
/// Provides `join_group`, `leave_group`, `publish_to`, `group_members`,
|
||||
|
|
|
|||
148
crates/std/src/timer_wheel.rs
Normal file
148
crates/std/src/timer_wheel.rs
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
use std::any::Any;
|
||||
|
||||
use swactor::actor::{ActorAddress, Message};
|
||||
use swactor::extension::WorkerExtension;
|
||||
|
||||
// ─── Cloneable Message Trait ────────────────────────────────────────────────
|
||||
|
||||
/// Type-erased cloneable message for interval timers.
|
||||
/// Since `Message: Clone`, all actor messages implement this.
|
||||
pub(crate) trait CloneMsg: Send {
|
||||
fn clone_boxed(&self) -> Box<dyn Any + Send>;
|
||||
}
|
||||
|
||||
impl<M: Message> CloneMsg for M {
|
||||
fn clone_boxed(&self) -> Box<dyn Any + Send> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Timer Request ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Timer request from a handler, queued for processing after tick_all.
|
||||
pub(crate) enum TimerRequest {
|
||||
/// One-shot: deliver `msg` to `dest` after `ticks` worker ticks.
|
||||
Once {
|
||||
dest: ActorAddress,
|
||||
msg: Box<dyn Any + Send>,
|
||||
ticks: u64,
|
||||
},
|
||||
/// Repeating: deliver a clone of `msg` to `dest` every `period` ticks.
|
||||
Interval {
|
||||
dest: ActorAddress,
|
||||
msg: Box<dyn CloneMsg>,
|
||||
period: u64,
|
||||
},
|
||||
}
|
||||
|
||||
// ─── Timer Wheel ────────────────────────────────────────────────────────────
|
||||
|
||||
struct OnceTimer {
|
||||
fire_at: u64,
|
||||
dest: ActorAddress,
|
||||
msg: Box<dyn Any + Send>,
|
||||
}
|
||||
|
||||
struct IntervalTimer {
|
||||
next_fire: u64,
|
||||
period: u64,
|
||||
dest: ActorAddress,
|
||||
msg: Box<dyn CloneMsg>,
|
||||
}
|
||||
|
||||
/// Per-worker tick-counting timer wheel.
|
||||
///
|
||||
/// Timers are deterministic (tick-counted, not wall-clock). One-shot timers
|
||||
/// fire once and are consumed; interval timers fire repeatedly every N ticks.
|
||||
pub struct TimerWheel {
|
||||
current_tick: u64,
|
||||
once_timers: Vec<OnceTimer>,
|
||||
interval_timers: Vec<IntervalTimer>,
|
||||
}
|
||||
|
||||
impl TimerWheel {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
current_tick: 0,
|
||||
once_timers: Vec::new(),
|
||||
interval_timers: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Advance the tick counter and collect all due timer messages.
|
||||
fn fire(&mut self) -> Vec<(ActorAddress, Box<dyn Any + Send>)> {
|
||||
self.current_tick += 1;
|
||||
let tick = self.current_tick;
|
||||
let mut result = Vec::new();
|
||||
|
||||
// Fire one-shot timers (swap-remove for O(1) removal)
|
||||
let mut i = 0;
|
||||
while i < self.once_timers.len() {
|
||||
if self.once_timers[i].fire_at <= tick {
|
||||
let timer = self.once_timers.swap_remove(i);
|
||||
result.push((timer.dest, timer.msg));
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Fire interval timers
|
||||
for timer in &mut self.interval_timers {
|
||||
if timer.next_fire <= tick {
|
||||
let msg = timer.msg.clone_boxed();
|
||||
result.push((timer.dest, msg));
|
||||
timer.next_fire = tick + timer.period;
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Remove interval timers whose target was just removed from the worker.
|
||||
fn gc_dead_intervals(&mut self, dead: &[ActorAddress]) {
|
||||
if dead.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.interval_timers
|
||||
.retain(|t| !dead.iter().any(|d| *d == t.dest));
|
||||
}
|
||||
|
||||
fn add_once(&mut self, dest: ActorAddress, msg: Box<dyn Any + Send>, ticks: u64) {
|
||||
self.once_timers.push(OnceTimer {
|
||||
fire_at: self.current_tick + ticks,
|
||||
dest,
|
||||
msg,
|
||||
});
|
||||
}
|
||||
|
||||
fn add_interval(&mut self, dest: ActorAddress, msg: Box<dyn CloneMsg>, period: u64) {
|
||||
let period = period.max(1); // prevent zero-period infinite loop
|
||||
self.interval_timers.push(IntervalTimer {
|
||||
next_fire: self.current_tick + period,
|
||||
period,
|
||||
dest,
|
||||
msg,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl WorkerExtension for TimerWheel {
|
||||
fn on_tick(&mut self) -> Vec<(ActorAddress, Box<dyn Any + Send>)> {
|
||||
self.fire()
|
||||
}
|
||||
|
||||
fn handle_request(&mut self, request: Box<dyn Any + Send>) {
|
||||
if let Ok(req) = request.downcast::<TimerRequest>() {
|
||||
match *req {
|
||||
TimerRequest::Once { dest, msg, ticks } => self.add_once(dest, msg, ticks),
|
||||
TimerRequest::Interval { dest, msg, period } => {
|
||||
self.add_interval(dest, msg, period)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn gc_dead(&mut self, dead: &[ActorAddress]) {
|
||||
self.gc_dead_intervals(dead);
|
||||
}
|
||||
}
|
||||
95
crates/std/src/watch_registry.rs
Normal file
95
crates/std/src/watch_registry.rs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use swactor::actor::{ActorAddress, ActorExited, ExitReason};
|
||||
|
||||
/// Tracks watch relationships between actors.
|
||||
///
|
||||
/// Thread-safe via interior `Mutex`. Watch/unwatch operations are rare
|
||||
/// relative to message sends, so contention is negligible.
|
||||
pub struct WatchRegistry {
|
||||
inner: Mutex<WatchState>,
|
||||
}
|
||||
|
||||
struct WatchState {
|
||||
/// target → set of watchers awaiting death notification
|
||||
watchers: HashMap<ActorAddress, HashSet<ActorAddress>>,
|
||||
/// watcher → set of targets it's watching (reverse index for cleanup)
|
||||
watching: HashMap<ActorAddress, HashSet<ActorAddress>>,
|
||||
}
|
||||
|
||||
impl WatchRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: Mutex::new(WatchState {
|
||||
watchers: HashMap::new(),
|
||||
watching: HashMap::new(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn watch(&self, watcher: ActorAddress, target: ActorAddress) {
|
||||
let mut state = self.inner.lock().unwrap();
|
||||
state.watchers.entry(target).or_default().insert(watcher);
|
||||
state.watching.entry(watcher).or_default().insert(target);
|
||||
}
|
||||
|
||||
pub fn unwatch(&self, watcher: ActorAddress, target: ActorAddress) {
|
||||
let mut state = self.inner.lock().unwrap();
|
||||
if let Some(set) = state.watchers.get_mut(&target) {
|
||||
set.remove(&watcher);
|
||||
if set.is_empty() {
|
||||
state.watchers.remove(&target);
|
||||
}
|
||||
}
|
||||
if let Some(set) = state.watching.get_mut(&watcher) {
|
||||
set.remove(&target);
|
||||
if set.is_empty() {
|
||||
state.watching.remove(&watcher);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Called when an actor dies. Returns (watcher_addr, ActorExited) pairs.
|
||||
pub fn notify_death(
|
||||
&self,
|
||||
target: ActorAddress,
|
||||
reason: ExitReason,
|
||||
) -> Vec<(ActorAddress, ActorExited)> {
|
||||
let mut state = self.inner.lock().unwrap();
|
||||
let notification = ActorExited {
|
||||
addr: target,
|
||||
reason,
|
||||
};
|
||||
let mut result = Vec::new();
|
||||
|
||||
if let Some(watcher_set) = state.watchers.remove(&target) {
|
||||
for watcher in &watcher_set {
|
||||
result.push((*watcher, notification.clone()));
|
||||
if let Some(set) = state.watching.get_mut(watcher) {
|
||||
set.remove(&target);
|
||||
if set.is_empty() {
|
||||
state.watching.remove(watcher);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Called when a watcher itself dies. Cleans up all its watching entries.
|
||||
pub fn cleanup_watcher(&self, watcher: &ActorAddress) {
|
||||
let mut state = self.inner.lock().unwrap();
|
||||
if let Some(targets) = state.watching.remove(watcher) {
|
||||
for target in targets {
|
||||
if let Some(set) = state.watchers.get_mut(&target) {
|
||||
set.remove(watcher);
|
||||
if set.is_empty() {
|
||||
state.watchers.remove(&target);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -16,11 +16,11 @@ messages.
|
|||
│ │ │ │
|
||||
│ │ address_map: Arc<AddressMap> -- actor -> worker lookup │ │
|
||||
│ │ inbox_registry: Arc<InboxRegistry> -- external inbox delivery │ │
|
||||
│ │ name_registry: Arc<NameRegistry> -- name -> address lookup │ │
|
||||
│ │ monitor_registry: Arc<MonitorRegistry> -- death watch subscripts │ │
|
||||
│ │ group_registry: Arc<GroupRegistry> -- pub-sub actor groups │ │
|
||||
│ │ placement: Placement -- load-aware worker picker │ │
|
||||
│ │ worker_stats: Vec<Arc<WorkerStats>> -- atomic stat counters │ │
|
||||
│ │ extension: Option<Arc<dyn RuntimeExtension>> │ │
|
||||
│ │ (StdExtension holds: NameRegistry, MonitorRegistry, │ │
|
||||
│ │ GroupRegistry, WatchRegistry) │ │
|
||||
│ │ │ │
|
||||
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
|
|
@ -75,35 +75,42 @@ only way for actors to interact with the outside world.
|
|||
│ inner: &dyn ContextInner -- polymorphic dispatch │
|
||||
│ self_addr: ActorAddress -- address of the current actor │
|
||||
│ │
|
||||
│ ┌─ Public API ────────────────────────────────────────────────────────┐ │
|
||||
│ ┌─ Core API ─────────────────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ ctx.self_addr() -> ActorAddress │ │
|
||||
│ │ ctx.send(addr, msg) -> Result<(), Error> │ │
|
||||
│ │ ctx.spawn(actor) -> Result<ActorAddress, Error> │ │
|
||||
│ │ ctx.spawn_named(name, actor) -> Result<ActorAddress, Error> │ │
|
||||
│ │ ctx.spawn_restartable(a, f, max) -> Result<ActorAddress, Error> │ │
|
||||
│ │ ctx.stop_self() │ │
|
||||
│ │ ctx.stop_actor(addr) -> Result<(), Error> │ │
|
||||
│ │ ctx.where_is(name) -> Option<ActorAddress> │ │
|
||||
│ │ ctx.monitor(target) -> MonitorRef │ │
|
||||
│ │ ctx.demonitor(mref) │ │
|
||||
│ │ ctx.join_group(group) │ │
|
||||
│ │ ctx.leave_group(group) │ │
|
||||
│ │ ctx.publish(group, msg) -> usize │ │
|
||||
│ │ ctx.group_members(group) -> Vec<ActorAddress> │ │
|
||||
│ │ ctx.send_after_ticks(addr, msg, n) │ │
|
||||
│ │ ctx.send_interval_ticks(addr, msg, period) │ │
|
||||
│ │ ctx.extension() -> Option<&dyn RuntimeExtension> │ │
|
||||
│ │ │ │
|
||||
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─ Extension Traits (swactor-std) ──────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ CtxNaming: spawn_named, where_is │ │
|
||||
│ │ CtxMonitoring: monitor, demonitor │ │
|
||||
│ │ CtxWatching: watch, unwatch │ │
|
||||
│ │ CtxGroups: join_group, leave_group, publish, group_members │ │
|
||||
│ │ CtxTimers: send_after_ticks, send_interval_ticks │ │
|
||||
│ │ │ │
|
||||
│ │ These use ctx.extension() + downcast to StdExtension. │ │
|
||||
│ │ Also: spawn_restartable (via CtxNaming) │ │
|
||||
│ │ │ │
|
||||
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─ ContextInner dispatch ─────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ Five methods: send_any, spawn_any, request_stop, │ │
|
||||
│ │ post_worker_request, extension │ │
|
||||
│ │ │ │
|
||||
│ │ In single-threaded mode: inner = &Runtime │ │
|
||||
│ │ send → transfer_txs[wid], spawn → spawn_txs[wid] │ │
|
||||
│ │ │ │
|
||||
│ │ In multi-threaded mode: inner = &WorkerContext │ │
|
||||
│ │ send → pending_local (same worker) or transfer_txs (cross) │ │
|
||||
│ │ spawn → spawn_txs[target_wid] │ │
|
||||
│ │ post_worker_request → worker_requests (drained phase 5.5) │ │
|
||||
│ │ │ │
|
||||
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
|
|
@ -287,15 +294,22 @@ Actors can stop other actors from handlers:
|
|||
ctx.stop_actor(other_addr)?; // PoisonPill semantics — queued after existing msgs
|
||||
```
|
||||
|
||||
## Per-Worker Timers
|
||||
## Per-Worker Timers (swactor-std)
|
||||
|
||||
Deterministic tick-counting timers (not wall-clock):
|
||||
Deterministic tick-counting timers (not wall-clock). Requires `StdExtension`
|
||||
and the `CtxTimers` extension trait:
|
||||
|
||||
```
|
||||
use swactor_std::CtxTimers;
|
||||
|
||||
ctx.send_after_ticks(addr, msg, 5); // one-shot: fires after 5 ticks
|
||||
ctx.send_interval_ticks(addr, msg, 10); // repeating: every 10 ticks
|
||||
```
|
||||
|
||||
The `TimerWheel` lives as a per-worker extension (`WorkerExtension`),
|
||||
created by `StdExtension::create_worker_extension()`. Timer requests are
|
||||
dispatched via `ctx.post_worker_request()` and processed in phase 5.5.
|
||||
|
||||
## RuntimeHandle
|
||||
|
||||
Returned by `run()`. Holds `Arc<Runtime>` and the thread `JoinHandle`s.
|
||||
|
|
|
|||
|
|
@ -48,10 +48,12 @@
|
|||
│ │ │ │
|
||||
│ └──────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─ TimerWheel ────────────────────────────────────────────────────┐ │
|
||||
│ │ current_tick: u64 │ │
|
||||
│ │ once_timers: Vec<OnceTimer> -- fire_at, dest, msg │ │
|
||||
│ │ interval_timers: Vec<IntervalTimer> -- period, dest, clone_msg │ │
|
||||
│ ┌─ worker_ext: Option<Box<dyn WorkerExtension>> ─────────────────┐ │
|
||||
│ │ Per-worker extension state, created by RuntimeExtension │ │
|
||||
│ │ factory. StdExtension provides a TimerWheel here. │ │
|
||||
│ │ on_tick() → fire due messages (phase 2.5) │ │
|
||||
│ │ handle_request() → schedule timers etc. (phase 5.5) │ │
|
||||
│ │ gc_dead() → clean up dead actor state (phase 7) │ │
|
||||
│ └──────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└────────────────────────────────────────────────────────────────────────┘
|
||||
|
|
@ -69,11 +71,9 @@ Lives on `Arc<Runtime>`, shared read-only across all worker threads.
|
|||
│ spawn_txs: &[Sender] -- one Sender per worker │
|
||||
│ placement: &Placement -- load-aware worker picker │
|
||||
│ inbox_registry: &InboxRegistry -- external Inbox<M> receivers │
|
||||
│ name_registry: &NameRegistry -- String -> ActorAddress │
|
||||
│ monitor_registry: &MonitorRegistry -- death watch subscriptions │
|
||||
│ group_registry: &GroupRegistry -- pub-sub actor groups │
|
||||
│ config: &RuntimeConfig -- budget, backoff, etc. │
|
||||
│ stats_hook: Option<&dyn Hook> -- per-tick stats callback │
|
||||
│ extension: Option<&dyn RuntimeExtension> -- shared ext │
|
||||
│ stats_hook: Option<&dyn StatsHook> -- per-tick stats callback │
|
||||
│ worker_threads: &[OnceLock<Thread>] -- for unpark on send/spawn │
|
||||
│ │
|
||||
└────────────────────────────────────────────────────────────────────────┘
|
||||
|
|
@ -147,19 +147,16 @@ Lives on `Arc<Runtime>`, shared read-only across all worker threads.
|
|||
│ └────────────────────────────────────────────────────────────────────┘│
|
||||
│ │ │
|
||||
│ v │
|
||||
│ PHASE 2.5 --- Fire Due Timers │
|
||||
│ PHASE 2.5 --- Fire Per-Worker Extension │
|
||||
│ ┌────────────────────────────────────────────────────────────────────┐│
|
||||
│ │ ││
|
||||
│ │ timers.fire() (advances tick counter, collects due messages) ││
|
||||
│ │ worker_ext.on_tick() → Vec<(dest, msg)> ││
|
||||
│ │ (StdExtension provides TimerWheel: advances tick, fires due) ││
|
||||
│ │ │ ││
|
||||
│ │ v ││
|
||||
│ │ for (dest, msg) in timer_msgs: ││
|
||||
│ │ ┌──────────────┬──────────────┬─────────────────┐ ││
|
||||
│ │ │ local actor │ other worker │ inbox/unknown │ ││
|
||||
│ │ │ │ │ │ ││
|
||||
│ │ │ pool.deliver │ transfer_tx │ inbox_registry │ ││
|
||||
│ │ │ │ + unpark │ .try_deliver() │ ││
|
||||
│ │ └──────────────┴──────────────┴─────────────────┘ ││
|
||||
│ │ for (dest, msg) in ext_msgs: ││
|
||||
│ │ route_to_pool_or_remote(pool, tc, dest, msg) ││
|
||||
│ │ local → pool.deliver | cross → transfer_tx | → inbox_registry ││
|
||||
│ │ ││
|
||||
│ └────────────────────────────────────────────────────────────────────┘│
|
||||
│ │ │
|
||||
|
|
@ -171,7 +168,7 @@ Lives on `Arc<Runtime>`, shared read-only across all worker threads.
|
|||
│ │ │ implements ContextInner │ ││
|
||||
│ │ │ pending_local: RefCell<Vec<(Addr, Box<Any>)>> │ ││
|
||||
│ │ │ stop_requests: RefCell<Vec<ActorAddress>> │ ││
|
||||
│ │ │ timer_requests: RefCell<Vec<TimerRequest>> │ ││
|
||||
│ │ │ worker_requests: RefCell<Vec<Box<dyn Any + Send>>> │ ││
|
||||
│ │ └────────────────────────────────────────────────────────────┘ ││
|
||||
│ │ ││
|
||||
│ │ for each (addr, slot) in pool: ││
|
||||
|
|
@ -215,10 +212,8 @@ Lives on `Arc<Runtime>`, shared read-only across all worker threads.
|
|||
│ │ │ spawn_txs[wid].send((addr, actor)) + unpark │ ││
|
||||
│ │ │ │ ││
|
||||
│ │ │ request_stop(addr): → stop_requests.push(addr) │ ││
|
||||
│ │ │ schedule_timer(req): → timer_requests.push(req) │ ││
|
||||
│ │ │ where_is(name): → name_registry.lookup(name) │ ││
|
||||
│ │ │ monitor(w, t): → monitor_registry.register(w, t) │ ││
|
||||
│ │ │ join_group(a, g): → group_registry.join(g, a) │ ││
|
||||
│ │ │ post_worker_request(r): → worker_requests.push(r) │ ││
|
||||
│ │ │ extension(): → tc.extension │ ││
|
||||
│ │ │ │ ││
|
||||
│ │ └────────────────────────────────────────────────────────────┘ ││
|
||||
│ │ ││
|
||||
|
|
@ -242,12 +237,12 @@ Lives on `Arc<Runtime>`, shared read-only across all worker threads.
|
|||
│ └────────────────────────────────────────────────────────────────────┘│
|
||||
│ │ │
|
||||
│ v │
|
||||
│ PHASE 5.5 --- Drain Timer Requests │
|
||||
│ PHASE 5.5 --- Drain Worker Extension Requests │
|
||||
│ ┌────────────────────────────────────────────────────────────────────┐│
|
||||
│ │ ││
|
||||
│ │ for request in timer_requests: ││
|
||||
│ │ Once { dest, msg, ticks } → timers.add_once(dest, msg, ticks) ││
|
||||
│ │ Interval { dest, msg, p } → timers.add_interval(dest, msg, p) ││
|
||||
│ │ for request in worker_requests: ││
|
||||
│ │ worker_ext.handle_request(request) ││
|
||||
│ │ (StdExtension: downcasts to TimerRequest, schedules timers) ││
|
||||
│ │ ││
|
||||
│ └────────────────────────────────────────────────────────────────────┘│
|
||||
│ │ │
|
||||
|
|
@ -272,23 +267,20 @@ Lives on `Arc<Runtime>`, shared read-only across all worker threads.
|
|||
│ │ stopping actors: call on_stop(&ctx) before removal ││
|
||||
│ │ poisoned actors: skip on_stop (state may be corrupt) ││
|
||||
│ │ ││
|
||||
│ │ for each dead (addr, reason): ││
|
||||
│ │ for each dead addr: ││
|
||||
│ │ address_map.remove(&addr) ││
|
||||
│ │ name_registry.unregister_by_addr(&addr) ││
|
||||
│ │ group_registry.cleanup(&addr) ││
|
||||
│ │ ││
|
||||
│ │ if extension installed: ││
|
||||
│ │ notifications = ext.on_actor_death(&dead) ││
|
||||
│ │ (StdExtension: emits Down/ActorExited, unregisters names, ││
|
||||
│ │ removes from groups, takes monitors) ││
|
||||
│ │ ext.cleanup_dead(&dead_addrs) ││
|
||||
│ │ route notifications via route_to_pool_or_remote() ││
|
||||
│ │ ││
|
||||
│ │ deliver any messages sent during on_stop callbacks ││
|
||||
│ │ ││
|
||||
│ │ emit Down notifications for monitored dead actors: ││
|
||||
│ │ for (addr, reason) in dead: ││
|
||||
│ │ watchers = monitor_registry.take_monitors(&addr) ││
|
||||
│ │ for each watcher: route Down { addr, reason } ││
|
||||
│ │ same-worker → pool.deliver ││
|
||||
│ │ cross-worker → transfer_tx + unpark ││
|
||||
│ │ inbox → inbox_registry.try_deliver ││
|
||||
│ │ monitor_registry.remove_watcher(&addr) ││
|
||||
│ │ ││
|
||||
│ │ timers.gc_dead_intervals(dead_addrs) ││
|
||||
│ │ worker_ext.gc_dead(&dead_addrs) ││
|
||||
│ │ (StdExtension: removes orphaned interval timers) ││
|
||||
│ │ ││
|
||||
│ └────────────────────────────────────────────────────────────────────┘│
|
||||
│ │
|
||||
|
|
@ -510,13 +502,12 @@ Who holds what:
|
|||
│ │ addr->wid │ │ load-aware │ │ addr->Sender│ │ AtomicBool │ │
|
||||
│ └─────┬─────┘ └──────┬─────┘ └──────┬──────┘ └──────┬─────┘ │
|
||||
│ │ │ │ │ │
|
||||
│ ┌─────────────┐ ┌──────────────┐ ┌─────────────┐ │
|
||||
│ │NameRegistry │ │MonitorRegist.│ │GroupRegistry │ │
|
||||
│ │ name->addr │ │ watched-> │ │ group->addrs │ │
|
||||
│ │ addr->name │ │ watchers │ │ addr->groups │ │
|
||||
│ └─────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
|
||||
│ │ │ │ │
|
||||
│ ┌─────┴───────────────┴──────────────┴───────────────────────────────┐ │
|
||||
│ ┌─ extension: Arc<dyn RuntimeExtension> ──────────────────────────┐ │
|
||||
│ │ StdExtension holds: NameRegistry, MonitorRegistry, │ │
|
||||
│ │ GroupRegistry, WatchRegistry (accessed via downcast) │ │
|
||||
│ └─────────────────────────────────┬───────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌─────────────────────────────────┴──────────────────────────────────┐ │
|
||||
│ │ TickContext (borrows all above) │ │
|
||||
│ └──────────────────────────┬──────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ cargo-fuzz = true
|
|||
libfuzzer-sys = { version = "0.4", features = ["arbitrary-derive"] }
|
||||
arbitrary = { version = "1", features = ["derive"] }
|
||||
swactor = { path = "..", default-features = true }
|
||||
swactor-std = { path = "../crates/std" }
|
||||
|
||||
# Prevent this from interfering with workspaces
|
||||
[workspace]
|
||||
|
|
|
|||
|
|
@ -7,9 +7,12 @@ use std::sync::OnceLock;
|
|||
use arbitrary::Arbitrary;
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use swactor::actor::{ActorAddress, ActorInterface};
|
||||
use swactor::config::RuntimeConfig;
|
||||
use swactor::runtime::{Ctx, Inbox, Runtime};
|
||||
use swactor_std::{CtxTimers, StdExtension};
|
||||
|
||||
// ─── Run Logging ────────────────────────────────────────────────────────────
|
||||
// FUZZ_LOG=1 → trace every run
|
||||
|
|
@ -881,7 +884,8 @@ fuzz_target!(|input: FuzzInput| {
|
|||
num_threads: 1,
|
||||
..Default::default()
|
||||
};
|
||||
let rt = Runtime::new(config);
|
||||
let rt = Runtime::new(config)
|
||||
.with_extension(Arc::new(StdExtension::new()));
|
||||
let mut state = FuzzState::new(rt, tracing);
|
||||
|
||||
let scenario_limit = input.scenarios.len().min(64);
|
||||
|
|
|
|||
84
src/actor.rs
84
src/actor.rs
|
|
@ -199,53 +199,21 @@ pub struct Down {
|
|||
/// Not a `Message` — intercepted in `tick_all` before reaching `handle_any`.
|
||||
pub(crate) struct StopSignal;
|
||||
|
||||
/// Type-erased cloneable message for interval timers.
|
||||
/// Since `Message: Clone`, all actor messages can implement this.
|
||||
pub(crate) trait CloneMsg: Send {
|
||||
fn clone_boxed(&self) -> Box<dyn Any + Send>;
|
||||
}
|
||||
|
||||
impl<M: Message> CloneMsg for M {
|
||||
fn clone_boxed(&self) -> Box<dyn Any + Send> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// Timer request from a handler, queued for processing after tick_all.
|
||||
pub(crate) enum TimerRequest {
|
||||
/// One-shot: deliver `msg` to `dest` after `ticks` worker ticks.
|
||||
Once {
|
||||
dest: ActorAddress,
|
||||
msg: Box<dyn Any + Send>,
|
||||
ticks: u64,
|
||||
},
|
||||
/// Repeating: deliver a clone of `msg` to `dest` every `period` ticks.
|
||||
Interval {
|
||||
dest: ActorAddress,
|
||||
msg: Box<dyn CloneMsg>,
|
||||
period: u64,
|
||||
},
|
||||
}
|
||||
|
||||
/// Object-safe inner trait for sending type-erased messages.
|
||||
///
|
||||
/// Minimal core interface: send, spawn, stop, timers, and extension access.
|
||||
/// Registry methods (naming, monitoring, groups) are provided by extension
|
||||
/// traits in `swactor-std`.
|
||||
/// Minimal core interface: send, spawn, stop, and extension access.
|
||||
/// Registry methods (naming, monitoring, groups) and timer scheduling
|
||||
/// are provided by extension traits in `swactor-std`.
|
||||
#[allow(private_interfaces)]
|
||||
pub trait ContextInner {
|
||||
fn send_any(&self, addr: ActorAddress, msg: Box<dyn Any + Send>) -> Result<(), Error>;
|
||||
fn spawn_any(&self, addr: ActorAddress, actor: Box<dyn AnyActor>);
|
||||
/// Request graceful stop for an actor. Takes effect after the current message.
|
||||
fn request_stop(&self, addr: ActorAddress);
|
||||
/// Schedule a timer (one-shot or interval).
|
||||
fn schedule_timer(&self, request: TimerRequest);
|
||||
/// Post a request to the per-worker extension (e.g., timer scheduling).
|
||||
fn post_worker_request(&self, request: Box<dyn Any + Send>);
|
||||
/// Access the runtime extension (if installed).
|
||||
fn extension(&self) -> Option<&dyn crate::extension::RuntimeExtension>;
|
||||
/// Register a watch: watcher receives ActorExited when target dies.
|
||||
fn watch(&self, watcher: ActorAddress, target: ActorAddress);
|
||||
/// Cancel a watch.
|
||||
fn unwatch(&self, watcher: ActorAddress, target: ActorAddress);
|
||||
}
|
||||
|
||||
/// Actor syscall interface — passed to `ActorInterface::handle()`.
|
||||
|
|
@ -307,46 +275,4 @@ impl<'a> Ctx<'a> {
|
|||
self.inner.send_any(addr, Box::new(StopSignal))
|
||||
}
|
||||
|
||||
/// Schedule a one-shot timer: deliver `msg` to `addr` after `ticks` worker ticks.
|
||||
///
|
||||
/// The message is delivered as a normal mailbox message during the fire tick,
|
||||
/// before `tick_all` processes messages. The timer is tick-counted (deterministic),
|
||||
/// not wall-clock based.
|
||||
pub fn send_after_ticks<M: Message>(&self, addr: ActorAddress, msg: M, ticks: u64) {
|
||||
self.inner.schedule_timer(TimerRequest::Once {
|
||||
dest: addr,
|
||||
msg: Box::new(msg),
|
||||
ticks,
|
||||
});
|
||||
}
|
||||
|
||||
/// Schedule a repeating timer: deliver a clone of `msg` to `addr` every `period` ticks.
|
||||
///
|
||||
/// The first delivery happens after `period` ticks. The message is cloned for each
|
||||
/// delivery. The timer continues until the target actor is stopped/poisoned.
|
||||
pub fn send_interval_ticks<M: Message>(&self, addr: ActorAddress, msg: M, period: u64) {
|
||||
self.inner.schedule_timer(TimerRequest::Interval {
|
||||
dest: addr,
|
||||
msg: Box::new(msg),
|
||||
period,
|
||||
});
|
||||
}
|
||||
|
||||
/// Watch another actor's liveness. If the target dies, this actor
|
||||
/// receives an `ActorExited` message in its mailbox.
|
||||
///
|
||||
/// Watching an already-dead or non-existent actor delivers
|
||||
/// `ActorExited { reason: Stopped }` on the next tick.
|
||||
///
|
||||
/// Calling watch() multiple times on the same target is idempotent —
|
||||
/// only one notification is delivered.
|
||||
pub fn watch(&self, target: ActorAddress) {
|
||||
self.inner.watch(self.self_addr, target);
|
||||
}
|
||||
|
||||
/// Stop watching an actor. No notification will be delivered if the
|
||||
/// target subsequently dies.
|
||||
pub fn unwatch(&self, target: ActorAddress) {
|
||||
self.inner.unwatch(self.self_addr, target);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,14 +2,13 @@ use std::any::Any;
|
|||
use std::collections::{HashMap, HashSet};
|
||||
use std::hash::{BuildHasher, Hasher};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock, RwLock};
|
||||
use std::sync::{Arc, OnceLock, RwLock};
|
||||
use std::thread::Thread;
|
||||
|
||||
use crate::actor::{ActorAddress, AnyActor, Message};
|
||||
use crate::channel::Sender;
|
||||
use crate::config::RuntimeConfig;
|
||||
use crate::stats::WorkerStats;
|
||||
use crate::worker::WatchRegistry;
|
||||
use crate::Error;
|
||||
|
||||
// ─── Identity Hasher for ActorAddress ───────────────────────────────────────
|
||||
|
|
@ -244,7 +243,6 @@ pub(crate) struct TickContext<'a> {
|
|||
pub(crate) stats_hook: Option<&'a dyn crate::stats::StatsHook>,
|
||||
/// Thread handles for waking parked workers on cross-worker sends.
|
||||
pub(crate) worker_threads: &'a [OnceLock<Thread>],
|
||||
pub(crate) watch_registry: Option<&'a Arc<Mutex<WatchRegistry>>>,
|
||||
#[cfg(feature = "transport")]
|
||||
pub(crate) codec_registry: Option<&'a crate::transport::CodecRegistry>,
|
||||
#[cfg(feature = "transport")]
|
||||
|
|
|
|||
|
|
@ -25,4 +25,29 @@ pub trait RuntimeExtension: Send + Sync {
|
|||
|
||||
/// Downcast support for Ctx extension traits.
|
||||
fn as_any(&self) -> &dyn Any;
|
||||
|
||||
/// Create a per-worker extension instance. Called once per worker during init.
|
||||
///
|
||||
/// Unlike `RuntimeExtension` (shared across all workers), each worker owns
|
||||
/// its own `WorkerExtension` instance for per-worker state like timer wheels.
|
||||
fn create_worker_extension(&self) -> Option<Box<dyn WorkerExtension>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-worker extension state, created by [`RuntimeExtension::create_worker_extension`].
|
||||
///
|
||||
/// Each worker owns its own instance. Core calls these methods during tick phases:
|
||||
/// - `on_tick`: phase 2.5 — before tick_all, returns messages to deliver
|
||||
/// - `handle_request`: phase 5.5 — processes deferred requests from handlers
|
||||
/// - `gc_dead`: after cleanup_dead — removes state for dead actors
|
||||
pub trait WorkerExtension: Send {
|
||||
/// Called each tick before tick_all. Returns messages to deliver.
|
||||
fn on_tick(&mut self) -> Vec<(ActorAddress, Box<dyn Any + Send>)>;
|
||||
|
||||
/// Process a deferred request posted during handle() via `post_worker_request`.
|
||||
fn handle_request(&mut self, request: Box<dyn Any + Send>);
|
||||
|
||||
/// Clean up state for dead actors.
|
||||
fn gc_dead(&mut self, dead: &[ActorAddress]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
use std::any::Any;
|
||||
use std::cell::RefCell;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::thread::Thread;
|
||||
use crate::Instant;
|
||||
|
||||
use crate::actor::{Actor, ActorAddress, ActorExited, ActorInterface, AnyActor, ExitReason, Message, StopSignal, TimerRequest};
|
||||
use crate::actor::{Actor, ActorAddress, ActorInterface, AnyActor, Message, StopSignal};
|
||||
use crate::channel::{Receiver, Sender};
|
||||
// Re-export config types so existing code using `runtime::RuntimeConfig` still works
|
||||
pub use crate::config::{BackoffPolicy, MailboxOverflow, RuntimeConfig};
|
||||
|
|
@ -16,7 +16,7 @@ use crate::extension::RuntimeExtension;
|
|||
use crate::stats::{StatsHook, WorkerStats};
|
||||
// Re-export stats types so existing code using `runtime::*` still works
|
||||
pub use crate::stats::{RuntimeStats, WorkerInfo};
|
||||
use crate::worker::{WatchRegistry, Worker};
|
||||
use crate::worker::Worker;
|
||||
use crate::Error;
|
||||
|
||||
/// Generic message inbox for receiving messages outside of the runtime.
|
||||
|
|
@ -107,7 +107,6 @@ pub struct Runtime {
|
|||
is_running: AtomicBool,
|
||||
worker_stats: Vec<Arc<WorkerStats>>,
|
||||
stats_hook: Option<Arc<dyn StatsHook>>,
|
||||
watch_registry: Arc<Mutex<WatchRegistry>>,
|
||||
/// Workers available for tick(). run() drains this and moves workers to threads.
|
||||
tick_workers: RefCell<Vec<Worker>>,
|
||||
/// Thread handles for waking parked workers. Set by workers on startup via OnceLock.
|
||||
|
|
@ -203,7 +202,6 @@ impl Runtime {
|
|||
is_running: AtomicBool::new(false),
|
||||
worker_stats,
|
||||
stats_hook: None,
|
||||
watch_registry: Arc::new(Mutex::new(WatchRegistry::new())),
|
||||
tick_workers: RefCell::new(workers),
|
||||
worker_threads,
|
||||
created_at: Instant::now(),
|
||||
|
|
@ -247,6 +245,12 @@ impl Runtime {
|
|||
///
|
||||
/// Must be called before `run()` or `tick()`.
|
||||
pub fn with_extension(mut self, ext: Arc<dyn RuntimeExtension>) -> Self {
|
||||
// Create per-worker extensions (e.g., timer wheels)
|
||||
for worker in self.tick_workers.get_mut().iter_mut() {
|
||||
if let Some(wext) = ext.create_worker_extension() {
|
||||
worker.worker_ext = Some(wext);
|
||||
}
|
||||
}
|
||||
self.extension = Some(ext);
|
||||
self
|
||||
}
|
||||
|
|
@ -310,7 +314,6 @@ impl Runtime {
|
|||
extension: self.extension.as_deref(),
|
||||
stats_hook: self.stats_hook.as_deref(),
|
||||
worker_threads: &self.worker_threads,
|
||||
watch_registry: Some(&self.watch_registry),
|
||||
#[cfg(feature = "transport")]
|
||||
codec_registry: self.codec_registry.as_deref(),
|
||||
#[cfg(feature = "transport")]
|
||||
|
|
@ -506,35 +509,13 @@ impl ContextInner for Runtime {
|
|||
}
|
||||
}
|
||||
|
||||
fn schedule_timer(&self, _request: TimerRequest) {
|
||||
// Timers are per-worker and tick-counted; scheduling from outside
|
||||
fn post_worker_request(&self, _request: Box<dyn Any + Send>) {
|
||||
// Worker requests (e.g., timers) are per-worker; posting from outside
|
||||
// a worker context (e.g., rt.spawn() callback) is not supported.
|
||||
// Use rt.send_to() with a delay loop instead.
|
||||
eprintln!("swactor: schedule_timer called outside worker context — ignored");
|
||||
eprintln!("swactor: post_worker_request called outside worker context — ignored");
|
||||
}
|
||||
|
||||
fn extension(&self) -> Option<&dyn RuntimeExtension> {
|
||||
self.extension.as_deref()
|
||||
}
|
||||
|
||||
fn watch(&self, watcher: ActorAddress, target: ActorAddress) {
|
||||
if self.address_map.lookup(&target).is_some() {
|
||||
self.watch_registry.lock().unwrap().watch(watcher, target);
|
||||
} else {
|
||||
// Target not found — deliver ActorExited immediately.
|
||||
let msg = ActorExited {
|
||||
addr: target,
|
||||
reason: ExitReason::Stopped,
|
||||
};
|
||||
// Route to watcher via transfer queue
|
||||
if let Some(wid) = self.address_map.lookup(&watcher) {
|
||||
self.transfer_txs[wid.as_usize()]
|
||||
.send(Envelope::new(watcher, Box::new(msg)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn unwatch(&self, watcher: ActorAddress, target: ActorAddress) {
|
||||
self.watch_registry.lock().unwrap().unwatch(watcher, target);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
456
src/worker.rs
456
src/worker.rs
|
|
@ -1,199 +1,40 @@
|
|||
use std::any::Any;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use crate::Instant;
|
||||
|
||||
use crate::actor::{ActorAddress, ActorExited, AnyActor, CloneMsg, ContextInner, Ctx, ExitReason, StopReason, StopSignal, TimerRequest};
|
||||
use crate::actor::{ActorAddress, AnyActor, ContextInner, Ctx, StopReason, StopSignal};
|
||||
use crate::channel::Receiver;
|
||||
use crate::config::MailboxOverflow;
|
||||
use crate::delivery::{AddrBuildHasher, AddrMap, Envelope, TickContext, WorkerId};
|
||||
use crate::stats::{ActorSnapshot, TickTiming, WorkerStats};
|
||||
use crate::Error;
|
||||
|
||||
// ─── Per-Worker Timer Wheel ─────────────────────────────────────────────────
|
||||
use crate::extension::WorkerExtension;
|
||||
|
||||
struct OnceTimer {
|
||||
fire_at: u64,
|
||||
/// Route a message: try local pool first, then address_map for cross-worker,
|
||||
/// then inbox_registry for external receivers.
|
||||
fn route_to_pool_or_remote(
|
||||
pool: &mut ActorPool,
|
||||
tc: &TickContext,
|
||||
dest: ActorAddress,
|
||||
msg: Box<dyn Any + Send>,
|
||||
}
|
||||
|
||||
struct IntervalTimer {
|
||||
next_fire: u64,
|
||||
period: u64,
|
||||
dest: ActorAddress,
|
||||
msg: Box<dyn CloneMsg>,
|
||||
}
|
||||
|
||||
/// Per-worker tick-counting timer wheel.
|
||||
///
|
||||
/// Timers are deterministic (tick-counted, not wall-clock). One-shot timers
|
||||
/// fire once and are consumed; interval timers fire repeatedly every N ticks.
|
||||
struct TimerWheel {
|
||||
current_tick: u64,
|
||||
once_timers: Vec<OnceTimer>,
|
||||
interval_timers: Vec<IntervalTimer>,
|
||||
}
|
||||
|
||||
impl TimerWheel {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
current_tick: 0,
|
||||
once_timers: Vec::new(),
|
||||
interval_timers: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Advance the tick counter and collect all due timer messages.
|
||||
/// Returns the messages to be routed by the caller (may target local or remote actors/inboxes).
|
||||
fn fire(&mut self) -> Vec<(ActorAddress, Box<dyn Any + Send>)> {
|
||||
self.current_tick += 1;
|
||||
let tick = self.current_tick;
|
||||
let mut result = Vec::new();
|
||||
|
||||
// Fire one-shot timers (swap-remove for O(1) removal)
|
||||
let mut i = 0;
|
||||
while i < self.once_timers.len() {
|
||||
if self.once_timers[i].fire_at <= tick {
|
||||
let timer = self.once_timers.swap_remove(i);
|
||||
result.push((timer.dest, timer.msg));
|
||||
) {
|
||||
if pool.contains(&dest) {
|
||||
pool.deliver(&dest, msg);
|
||||
} else {
|
||||
i += 1;
|
||||
match tc.address_map.lookup(&dest) {
|
||||
Some(wid) => {
|
||||
tc.transfer_txs[wid.as_usize()].send(Envelope::new(dest, msg));
|
||||
crate::runtime::notify_worker(tc.worker_threads, wid.as_usize());
|
||||
}
|
||||
None => {
|
||||
let _ = tc.inbox_registry.try_deliver(dest, msg);
|
||||
}
|
||||
}
|
||||
|
||||
// Fire interval timers
|
||||
for timer in &mut self.interval_timers {
|
||||
if timer.next_fire <= tick {
|
||||
let msg = timer.msg.clone_boxed();
|
||||
result.push((timer.dest, msg));
|
||||
timer.next_fire = tick + timer.period;
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Remove interval timers whose target was just removed from the worker.
|
||||
/// Only GCs timers for addresses in `dead` — inboxes and cross-worker actors
|
||||
/// are not in the local pool but are still valid targets.
|
||||
fn gc_dead_intervals(&mut self, dead: &[ActorAddress]) {
|
||||
if dead.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.interval_timers.retain(|t| !dead.iter().any(|d| *d == t.dest));
|
||||
}
|
||||
|
||||
/// Add a one-shot timer.
|
||||
fn add_once(&mut self, dest: ActorAddress, msg: Box<dyn Any + Send>, ticks: u64) {
|
||||
self.once_timers.push(OnceTimer {
|
||||
fire_at: self.current_tick + ticks,
|
||||
dest,
|
||||
msg,
|
||||
});
|
||||
}
|
||||
|
||||
/// Add an interval timer. First fire is after `period` ticks.
|
||||
fn add_interval(&mut self, dest: ActorAddress, msg: Box<dyn CloneMsg>, period: u64) {
|
||||
let period = period.max(1); // prevent zero-period infinite loop
|
||||
self.interval_timers.push(IntervalTimer {
|
||||
next_fire: self.current_tick + period,
|
||||
period,
|
||||
dest,
|
||||
msg,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Watch Registry ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Tracks watch relationships between actors.
|
||||
///
|
||||
/// Shared across workers via `Arc<Mutex<_>>`. Contention is negligible
|
||||
/// because watch/unwatch operations are rare relative to message sends.
|
||||
pub(crate) struct WatchRegistry {
|
||||
/// target → set of watchers awaiting death notification
|
||||
watchers: HashMap<ActorAddress, HashSet<ActorAddress>>,
|
||||
/// watcher → set of targets it's watching (reverse index for cleanup)
|
||||
watching: HashMap<ActorAddress, HashSet<ActorAddress>>,
|
||||
}
|
||||
|
||||
impl WatchRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
watchers: HashMap::new(),
|
||||
watching: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn watch(&mut self, watcher: ActorAddress, target: ActorAddress) {
|
||||
self.watchers.entry(target).or_default().insert(watcher);
|
||||
self.watching.entry(watcher).or_default().insert(target);
|
||||
}
|
||||
|
||||
pub fn unwatch(&mut self, watcher: ActorAddress, target: ActorAddress) {
|
||||
if let Some(set) = self.watchers.get_mut(&target) {
|
||||
set.remove(&watcher);
|
||||
if set.is_empty() {
|
||||
self.watchers.remove(&target);
|
||||
}
|
||||
}
|
||||
if let Some(set) = self.watching.get_mut(&watcher) {
|
||||
set.remove(&target);
|
||||
if set.is_empty() {
|
||||
self.watching.remove(&watcher);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Called when an actor dies. Returns (watcher_addr, ActorExited) pairs.
|
||||
pub fn notify_death(
|
||||
&mut self,
|
||||
target: ActorAddress,
|
||||
reason: ExitReason,
|
||||
) -> Vec<(ActorAddress, ActorExited)> {
|
||||
let notification = ActorExited {
|
||||
addr: target,
|
||||
reason,
|
||||
};
|
||||
let mut result = Vec::new();
|
||||
|
||||
if let Some(watcher_set) = self.watchers.remove(&target) {
|
||||
for watcher in &watcher_set {
|
||||
result.push((*watcher, notification.clone()));
|
||||
// clean up reverse index
|
||||
if let Some(set) = self.watching.get_mut(watcher) {
|
||||
set.remove(&target);
|
||||
if set.is_empty() {
|
||||
self.watching.remove(watcher);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Called when a watcher itself dies. Cleans up all its watching entries.
|
||||
pub fn cleanup_watcher(&mut self, watcher: &ActorAddress) {
|
||||
if let Some(targets) = self.watching.remove(watcher) {
|
||||
for target in targets {
|
||||
if let Some(set) = self.watchers.get_mut(&target) {
|
||||
set.remove(watcher);
|
||||
if set.is_empty() {
|
||||
self.watchers.remove(&target);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a target has any watchers registered.
|
||||
pub fn has_watchers(&self, target: &ActorAddress) -> bool {
|
||||
self.watchers.get(target).is_some_and(|s| !s.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -208,8 +49,8 @@ pub(crate) struct Worker {
|
|||
stats: Arc<WorkerStats>,
|
||||
/// Reusable scratch buffer for building per-actor snapshots.
|
||||
snapshot_buf: Vec<ActorSnapshot>,
|
||||
/// Per-worker tick-counting timer wheel.
|
||||
timers: TimerWheel,
|
||||
/// Per-worker extension (e.g., timer wheel). Created by RuntimeExtension factory.
|
||||
pub(crate) worker_ext: Option<Box<dyn WorkerExtension>>,
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
|
|
@ -228,19 +69,15 @@ impl Worker {
|
|||
spawn_rx,
|
||||
stats,
|
||||
snapshot_buf: Vec::new(),
|
||||
timers: TimerWheel::new(),
|
||||
worker_ext: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Run one iteration of the worker loop. Returns `true` if any work was done.
|
||||
pub(crate) fn tick_once(&mut self, tc: &TickContext) -> bool {
|
||||
#[cfg(feature = "tracing")]
|
||||
let _span = tracing::trace_span!("worker.tick", worker_id = self.id.0).entered();
|
||||
|
||||
/// Drain the spawn queue, inserting new actors into the pool.
|
||||
/// Used in phases 1 and 4 of tick_once.
|
||||
fn drain_spawns(&mut self) -> bool {
|
||||
let mut did_work = false;
|
||||
let t0 = Instant::now();
|
||||
|
||||
// 1. Drain spawn queue → add actors to pool
|
||||
#[cfg(feature = "tracing")]
|
||||
let mut spawn_count: usize = 0;
|
||||
while let Some((addr, actor)) = self.spawn_rx.try_recv() {
|
||||
|
|
@ -253,6 +90,68 @@ impl Worker {
|
|||
if spawn_count > 0 {
|
||||
tracing::debug!(worker_id = self.id.0, count = spawn_count, "worker.spawns_drained");
|
||||
}
|
||||
did_work
|
||||
}
|
||||
|
||||
/// Phase 7: clean up dead actors, deliver death notifications, GC extension state.
|
||||
fn cleanup_dead_actors(&mut self, tc: &TickContext) -> bool {
|
||||
let cleanup_pending: RefCell<Vec<(ActorAddress, Box<dyn Any + Send>)>> =
|
||||
RefCell::new(Vec::new());
|
||||
let cleanup_stops: RefCell<Vec<ActorAddress>> = RefCell::new(Vec::new());
|
||||
let cleanup_requests: RefCell<Vec<Box<dyn Any + Send>>> = RefCell::new(Vec::new());
|
||||
let dead = {
|
||||
let cleanup_ctx = WorkerContext {
|
||||
worker_id: self.id,
|
||||
tc,
|
||||
pending_local: &cleanup_pending,
|
||||
stop_requests: &cleanup_stops,
|
||||
worker_requests: &cleanup_requests,
|
||||
stats: &self.stats,
|
||||
};
|
||||
self.pool.cleanup_dead(&cleanup_ctx)
|
||||
};
|
||||
|
||||
let had_dead = !dead.is_empty();
|
||||
if had_dead {
|
||||
for &(addr, _) in &dead {
|
||||
tc.address_map.remove(&addr);
|
||||
}
|
||||
|
||||
if let Some(ext) = tc.extension {
|
||||
let notifications = ext.on_actor_death(&dead);
|
||||
let dead_addrs: Vec<_> = dead.iter().map(|(a, _)| *a).collect();
|
||||
ext.cleanup_dead(&dead_addrs);
|
||||
for (dest, msg) in notifications {
|
||||
route_to_pool_or_remote(&mut self.pool, tc, dest, msg);
|
||||
}
|
||||
}
|
||||
|
||||
self.stats.num_actors.store(self.pool.len(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
// Deliver any messages sent during on_stop callbacks
|
||||
for (addr, msg) in cleanup_pending.into_inner() {
|
||||
self.pool.deliver(&addr, msg);
|
||||
}
|
||||
|
||||
// GC per-worker extension state for dead actors
|
||||
if let Some(ext) = &mut self.worker_ext {
|
||||
let dead_addrs: Vec<ActorAddress> = dead.iter().map(|(a, _)| *a).collect();
|
||||
ext.gc_dead(&dead_addrs);
|
||||
}
|
||||
|
||||
had_dead
|
||||
}
|
||||
|
||||
pub(crate) fn tick_once(&mut self, tc: &TickContext) -> bool {
|
||||
#[cfg(feature = "tracing")]
|
||||
let _span = tracing::trace_span!("worker.tick", worker_id = self.id.0).entered();
|
||||
|
||||
let mut did_work = false;
|
||||
let t0 = Instant::now();
|
||||
|
||||
// 1. Drain spawn queue → add actors to pool
|
||||
did_work |= self.drain_spawns();
|
||||
let t1 = Instant::now();
|
||||
|
||||
// 2. Drain transfer queue → deliver envelopes to actors
|
||||
|
|
@ -264,24 +163,12 @@ impl Worker {
|
|||
}
|
||||
let t2 = Instant::now();
|
||||
|
||||
// 2.5. Fire due timers → deliver to mailboxes before tick_all
|
||||
let timer_msgs = self.timers.fire();
|
||||
for (dest, msg) in timer_msgs {
|
||||
if self.pool.contains(&dest) {
|
||||
// Same-worker: deliver directly to actor's mailbox
|
||||
self.pool.deliver(&dest, msg);
|
||||
} else {
|
||||
// Inbox or cross-worker: route through address map / inbox registry
|
||||
match tc.address_map.lookup(&dest) {
|
||||
Some(wid) => {
|
||||
tc.transfer_txs[wid.as_usize()].send(Envelope::new(dest, msg));
|
||||
crate::runtime::notify_worker(tc.worker_threads, wid.as_usize());
|
||||
}
|
||||
None => {
|
||||
let _ = tc.inbox_registry.try_deliver(dest, msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 2.5. Fire per-worker extension (e.g., timers) → deliver before tick_all
|
||||
let ext_msgs: Vec<_> = self.worker_ext.as_mut()
|
||||
.map(|ext| ext.on_tick())
|
||||
.unwrap_or_default();
|
||||
for (dest, msg) in ext_msgs {
|
||||
route_to_pool_or_remote(&mut self.pool, tc, dest, msg);
|
||||
did_work = true;
|
||||
}
|
||||
|
||||
|
|
@ -289,20 +176,19 @@ impl Worker {
|
|||
let pending_local: RefCell<Vec<(ActorAddress, Box<dyn Any + Send>)>> =
|
||||
RefCell::new(Vec::new());
|
||||
let stop_requests: RefCell<Vec<ActorAddress>> = RefCell::new(Vec::new());
|
||||
let timer_requests: RefCell<Vec<TimerRequest>> = RefCell::new(Vec::new());
|
||||
let worker_requests: RefCell<Vec<Box<dyn Any + Send>>> = RefCell::new(Vec::new());
|
||||
|
||||
let processed;
|
||||
let deaths;
|
||||
{
|
||||
let worker_ctx = WorkerContext {
|
||||
worker_id: self.id,
|
||||
tc,
|
||||
pending_local: &pending_local,
|
||||
stop_requests: &stop_requests,
|
||||
timer_requests: &timer_requests,
|
||||
worker_requests: &worker_requests,
|
||||
stats: &self.stats,
|
||||
};
|
||||
(processed, deaths) = self.pool.tick_all(&worker_ctx, &self.stats, tc.config.actor_message_budget, &stop_requests);
|
||||
processed = self.pool.tick_all(&worker_ctx, &self.stats, tc.config.actor_message_budget, &stop_requests);
|
||||
if processed > 0 {
|
||||
did_work = true;
|
||||
}
|
||||
|
|
@ -320,10 +206,7 @@ impl Worker {
|
|||
|
||||
// 4. Drain spawn queue again — actors spawned during step 3
|
||||
// must be in the pool before pending_local delivery.
|
||||
while let Some((addr, actor)) = self.spawn_rx.try_recv() {
|
||||
self.pool.insert(addr, actor);
|
||||
did_work = true;
|
||||
}
|
||||
did_work |= self.drain_spawns();
|
||||
let t4 = Instant::now();
|
||||
|
||||
// 5. Drain pending_local buffer → deliver to local actors
|
||||
|
|
@ -335,48 +218,10 @@ impl Worker {
|
|||
self.pool.deliver(&addr, msg);
|
||||
}
|
||||
|
||||
// 5.5. Process timer requests from handlers
|
||||
for request in timer_requests.into_inner() {
|
||||
match request {
|
||||
TimerRequest::Once { dest, msg, ticks } => {
|
||||
self.timers.add_once(dest, msg, ticks);
|
||||
}
|
||||
TimerRequest::Interval { dest, msg, period } => {
|
||||
self.timers.add_interval(dest, msg, period);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5b. Process actor deaths → deliver ActorExited to watchers
|
||||
if !deaths.is_empty() {
|
||||
did_work = true;
|
||||
if let Some(registry) = &tc.watch_registry {
|
||||
let mut reg = registry.lock().unwrap();
|
||||
for (dead_addr, reason) in deaths {
|
||||
let notifications = reg.notify_death(dead_addr, reason);
|
||||
for (watcher_addr, msg) in notifications {
|
||||
// Deliver ActorExited as a normal message via the address map
|
||||
match tc.address_map.lookup(&watcher_addr) {
|
||||
Some(wid) if wid == self.id => {
|
||||
self.pool.deliver(&watcher_addr, Box::new(msg));
|
||||
}
|
||||
Some(wid) => {
|
||||
tc.transfer_txs[wid.as_usize()]
|
||||
.send(Envelope::new(watcher_addr, Box::new(msg)));
|
||||
}
|
||||
None => {
|
||||
// Watcher not in address map — may be an inbox or remote.
|
||||
// Try inbox registry as best effort.
|
||||
let _ = tc.inbox_registry.try_deliver(
|
||||
watcher_addr,
|
||||
Box::new(msg),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Clean up the dead actor's own watches (things it was watching)
|
||||
reg.cleanup_watcher(&dead_addr);
|
||||
}
|
||||
// 5.5. Process worker extension requests from handlers (e.g., timer scheduling)
|
||||
if let Some(ext) = &mut self.worker_ext {
|
||||
for request in worker_requests.into_inner() {
|
||||
ext.handle_request(request);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -427,67 +272,7 @@ impl Worker {
|
|||
}
|
||||
|
||||
// 7. Clean up poisoned and stopping actors
|
||||
// on_stop() may send messages, so provide a fresh pending_local buffer.
|
||||
let cleanup_pending: RefCell<Vec<(ActorAddress, Box<dyn Any + Send>)>> =
|
||||
RefCell::new(Vec::new());
|
||||
let cleanup_stops: RefCell<Vec<ActorAddress>> = RefCell::new(Vec::new());
|
||||
let cleanup_timers: RefCell<Vec<TimerRequest>> = RefCell::new(Vec::new());
|
||||
let dead = {
|
||||
let cleanup_ctx = WorkerContext {
|
||||
worker_id: self.id,
|
||||
tc,
|
||||
pending_local: &cleanup_pending,
|
||||
stop_requests: &cleanup_stops,
|
||||
timer_requests: &cleanup_timers,
|
||||
stats: &self.stats,
|
||||
};
|
||||
let dead = self.pool.cleanup_dead(&cleanup_ctx);
|
||||
if !dead.is_empty() {
|
||||
for &(addr, _) in &dead {
|
||||
tc.address_map.remove(&addr);
|
||||
}
|
||||
|
||||
if let Some(ext) = tc.extension {
|
||||
// Get death notifications (monitors) before cleaning up state
|
||||
let notifications = ext.on_actor_death(&dead);
|
||||
|
||||
// Clean up extension state (names, groups, dead watcher monitors)
|
||||
let dead_addrs: Vec<_> = dead.iter().map(|(a, _)| *a).collect();
|
||||
ext.cleanup_dead(&dead_addrs);
|
||||
|
||||
// Deliver Down notifications through normal routing
|
||||
for (dest, msg) in notifications {
|
||||
if self.pool.contains(&dest) {
|
||||
self.pool.deliver(&dest, msg);
|
||||
} else {
|
||||
match tc.address_map.lookup(&dest) {
|
||||
Some(wid) => {
|
||||
tc.transfer_txs[wid.as_usize()]
|
||||
.send(Envelope::new(dest, msg));
|
||||
crate::runtime::notify_worker(tc.worker_threads, wid.as_usize());
|
||||
}
|
||||
None => {
|
||||
let _ = tc.inbox_registry.try_deliver(dest, msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-publish num_actors after cleanup so stats reflect removal
|
||||
self.stats.num_actors.store(self.pool.len(), Ordering::Relaxed);
|
||||
did_work = true;
|
||||
}
|
||||
dead
|
||||
};
|
||||
// Deliver any messages sent during on_stop callbacks
|
||||
for (addr, msg) in cleanup_pending.into_inner() {
|
||||
self.pool.deliver(&addr, msg);
|
||||
}
|
||||
|
||||
// GC orphaned interval timers for actors that were just removed
|
||||
let dead_addrs: Vec<ActorAddress> = dead.iter().map(|(a, _)| *a).collect();
|
||||
self.timers.gc_dead_intervals(&dead_addrs);
|
||||
did_work |= self.cleanup_dead_actors(tc);
|
||||
|
||||
did_work
|
||||
}
|
||||
|
|
@ -531,7 +316,7 @@ struct WorkerContext<'a> {
|
|||
tc: &'a TickContext<'a>,
|
||||
pending_local: &'a RefCell<Vec<(ActorAddress, Box<dyn Any + Send>)>>,
|
||||
stop_requests: &'a RefCell<Vec<ActorAddress>>,
|
||||
timer_requests: &'a RefCell<Vec<TimerRequest>>,
|
||||
worker_requests: &'a RefCell<Vec<Box<dyn Any + Send>>>,
|
||||
stats: &'a WorkerStats,
|
||||
}
|
||||
|
||||
|
|
@ -568,36 +353,13 @@ impl ContextInner for WorkerContext<'_> {
|
|||
self.stop_requests.borrow_mut().push(addr);
|
||||
}
|
||||
|
||||
fn schedule_timer(&self, request: TimerRequest) {
|
||||
self.timer_requests.borrow_mut().push(request);
|
||||
fn post_worker_request(&self, request: Box<dyn Any + Send>) {
|
||||
self.worker_requests.borrow_mut().push(request);
|
||||
}
|
||||
|
||||
fn extension(&self) -> Option<&dyn crate::extension::RuntimeExtension> {
|
||||
self.tc.extension
|
||||
}
|
||||
|
||||
fn watch(&self, watcher: ActorAddress, target: ActorAddress) {
|
||||
if let Some(registry) = &self.tc.watch_registry {
|
||||
// Check if target exists in the address map
|
||||
if self.tc.address_map.lookup(&target).is_some() {
|
||||
registry.lock().unwrap().watch(watcher, target);
|
||||
} else {
|
||||
// Target not found — deliver ActorExited { reason: Stopped } immediately.
|
||||
// Buffer in pending_local so it arrives on next tick.
|
||||
let msg = ActorExited {
|
||||
addr: target,
|
||||
reason: ExitReason::Stopped,
|
||||
};
|
||||
self.pending_local.borrow_mut().push((watcher, Box::new(msg)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn unwatch(&self, watcher: ActorAddress, target: ActorAddress) {
|
||||
if let Some(registry) = &self.tc.watch_registry {
|
||||
registry.lock().unwrap().unwatch(watcher, target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ActorSlot {
|
||||
|
|
@ -678,7 +440,7 @@ impl ActorPool {
|
|||
std::mem::replace(&mut self.drops_this_tick, 0)
|
||||
}
|
||||
|
||||
/// Tick all actors in the pool. Returns (messages_processed, newly_dead_actors).
|
||||
/// Tick all actors in the pool. Returns the number of messages processed.
|
||||
///
|
||||
/// Each actor processes up to `budget` messages per tick (0 = unlimited).
|
||||
/// This prevents a single hot actor from starving others on the same worker.
|
||||
|
|
@ -688,9 +450,8 @@ impl ActorPool {
|
|||
stats: &WorkerStats,
|
||||
budget: usize,
|
||||
stop_requests: &RefCell<Vec<ActorAddress>>,
|
||||
) -> (usize, Vec<(ActorAddress, ExitReason)>) {
|
||||
) -> usize {
|
||||
let mut count = 0;
|
||||
let mut deaths = Vec::new();
|
||||
for (&addr, slot) in self.actors.iter_mut() {
|
||||
if slot.poisoned || slot.stopping {
|
||||
// Discard all messages for poisoned/stopping actors
|
||||
|
|
@ -735,7 +496,6 @@ impl ActorPool {
|
|||
slot.stopping = true;
|
||||
stats.stops.fetch_add(1, Ordering::Relaxed);
|
||||
slot.mailbox.clear();
|
||||
deaths.push((addr, ExitReason::Stopped));
|
||||
#[cfg(feature = "tracing")]
|
||||
tracing::info!(actor_addr = %addr, "actor.stop_requested");
|
||||
break;
|
||||
|
|
@ -756,7 +516,6 @@ impl ActorPool {
|
|||
tracing::error!(actor_addr = %addr, "actor.panicked");
|
||||
slot.poisoned = true;
|
||||
slot.mailbox.clear();
|
||||
deaths.push((addr, ExitReason::Panicked));
|
||||
break;
|
||||
}
|
||||
Ok(Some(type_name)) => {
|
||||
|
|
@ -775,7 +534,6 @@ impl ActorPool {
|
|||
slot.stopping = true;
|
||||
stats.stops.fetch_add(1, Ordering::Relaxed);
|
||||
slot.mailbox.clear();
|
||||
deaths.push((addr, ExitReason::Stopped));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -785,7 +543,7 @@ impl ActorPool {
|
|||
}
|
||||
}
|
||||
}
|
||||
(count, deaths)
|
||||
count
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
|
|
|
|||
245
tests/common/mod.rs
Normal file
245
tests/common/mod.rs
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
// Shared types and helpers for runtime test files.
|
||||
|
||||
#![allow(dead_code, unused_imports)]
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub use swactor::actor::{ActorAddress, ActorInterface, Down, MonitorRef, StopReason};
|
||||
pub use swactor::runtime::{Ctx, Inbox, MailboxOverflow, Runtime, RuntimeConfig};
|
||||
pub use swactor_std::{
|
||||
ChildSpec, CtxGroups, CtxMonitoring, CtxNaming, CtxTimers, RestartPolicy, Router,
|
||||
RoutingStrategy, RuntimeGroups, RuntimeNaming, StdExtension, Supervisor, SupervisorStrategy,
|
||||
};
|
||||
|
||||
// ── Messages ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Ping {
|
||||
pub reply_to: ActorAddress,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Pong;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Increment {
|
||||
pub reply_to: ActorAddress,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Count(pub usize);
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Forward {
|
||||
pub value: usize,
|
||||
pub reply_to: ActorAddress,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Done(pub usize);
|
||||
|
||||
/// Ask an actor for its own address.
|
||||
#[derive(Clone)]
|
||||
pub struct WhoAreYou {
|
||||
pub reply_to: ActorAddress,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct MyAddr(pub ActorAddress);
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PanicMsg;
|
||||
|
||||
/// Tells FanOutActor to distribute work.
|
||||
#[derive(Clone)]
|
||||
pub struct FanOut {
|
||||
pub count: usize,
|
||||
pub reply_to: ActorAddress,
|
||||
}
|
||||
|
||||
/// Message used in the chain test -- carries remaining hops and final reply address.
|
||||
#[derive(Clone)]
|
||||
pub struct ChainMsg {
|
||||
pub remaining: usize,
|
||||
pub depth: usize,
|
||||
pub reply_to: ActorAddress,
|
||||
}
|
||||
|
||||
// ── Actors ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Replies Pong to every Ping. Stateless.
|
||||
pub struct PingPongActor;
|
||||
|
||||
impl ActorInterface for PingPongActor {
|
||||
type Incoming = Ping;
|
||||
type Response = Pong;
|
||||
fn handle(&mut self, ctx: &Ctx, msg: Ping) {
|
||||
let _ = ctx.send(msg.reply_to, Pong);
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts Increment messages, replies Count(n) after each.
|
||||
pub struct CounterActor {
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
impl ActorInterface for CounterActor {
|
||||
type Incoming = Increment;
|
||||
type Response = Count;
|
||||
fn handle(&mut self, ctx: &Ctx, msg: Increment) {
|
||||
self.count += 1;
|
||||
let _ = ctx.send(msg.reply_to, Count(self.count));
|
||||
}
|
||||
}
|
||||
|
||||
/// Replies Done(value * 2).
|
||||
pub struct DoubleActor;
|
||||
|
||||
impl ActorInterface for DoubleActor {
|
||||
type Incoming = Forward;
|
||||
type Response = Done;
|
||||
fn handle(&mut self, ctx: &Ctx, msg: Forward) {
|
||||
let _ = ctx.send(msg.reply_to, Done(msg.value * 2));
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns a DoubleActor child and forwards the work to it.
|
||||
pub struct DelegatorActor;
|
||||
|
||||
impl ActorInterface for DelegatorActor {
|
||||
type Incoming = Forward;
|
||||
type Response = ();
|
||||
fn handle(&mut self, ctx: &Ctx, msg: Forward) {
|
||||
let child = ctx.spawn(DoubleActor).unwrap();
|
||||
let _ = ctx.send(child, Forward { value: msg.value, reply_to: msg.reply_to });
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns a child chain: each level spawns the next until remaining == 0,
|
||||
/// then the leaf replies Done(depth).
|
||||
pub struct ChainActor;
|
||||
|
||||
impl ActorInterface for ChainActor {
|
||||
type Incoming = ChainMsg;
|
||||
type Response = Done;
|
||||
fn handle(&mut self, ctx: &Ctx, msg: ChainMsg) {
|
||||
if msg.remaining == 0 {
|
||||
let _ = ctx.send(msg.reply_to, Done(msg.depth));
|
||||
} else {
|
||||
let child = ctx.spawn(ChainActor).unwrap();
|
||||
let _ = ctx.send(
|
||||
child,
|
||||
ChainMsg {
|
||||
remaining: msg.remaining - 1,
|
||||
depth: msg.depth + 1,
|
||||
reply_to: msg.reply_to,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns N DoubleActor children, sends Forward { value: i, reply_to } to each.
|
||||
pub struct FanOutActor;
|
||||
|
||||
impl ActorInterface for FanOutActor {
|
||||
type Incoming = FanOut;
|
||||
type Response = ();
|
||||
fn handle(&mut self, ctx: &Ctx, msg: FanOut) {
|
||||
for i in 1..=msg.count {
|
||||
let child = ctx.spawn(DoubleActor).unwrap();
|
||||
let _ = ctx.send(child, Forward { value: i, reply_to: msg.reply_to });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Replies with its own address.
|
||||
pub struct SelfAddrActor;
|
||||
|
||||
impl ActorInterface for SelfAddrActor {
|
||||
type Incoming = WhoAreYou;
|
||||
type Response = MyAddr;
|
||||
fn handle(&mut self, ctx: &Ctx, msg: WhoAreYou) {
|
||||
let _ = ctx.send(msg.reply_to, MyAddr(ctx.self_addr()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Panics on every message. Used to test panic isolation.
|
||||
pub struct PanicActor;
|
||||
|
||||
impl ActorInterface for PanicActor {
|
||||
type Incoming = PanicMsg;
|
||||
type Response = ();
|
||||
fn handle(&mut self, _ctx: &Ctx, _msg: PanicMsg) {
|
||||
panic!("intentional test panic");
|
||||
}
|
||||
}
|
||||
|
||||
/// Increments a shared counter on each Ping. Used to observe processing from outside.
|
||||
pub struct CountingPingActor {
|
||||
pub counter: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl ActorInterface for CountingPingActor {
|
||||
type Incoming = Ping;
|
||||
type Response = Pong;
|
||||
fn handle(&mut self, ctx: &Ctx, msg: Ping) {
|
||||
self.counter.fetch_add(1, Ordering::SeqCst);
|
||||
let _ = ctx.send(msg.reply_to, Pong);
|
||||
}
|
||||
}
|
||||
|
||||
/// Null actor that accepts Ping but does nothing visible.
|
||||
pub struct NullActor;
|
||||
|
||||
impl ActorInterface for NullActor {
|
||||
type Incoming = Ping;
|
||||
type Response = ();
|
||||
fn handle(&mut self, _ctx: &Ctx, _msg: Ping) {}
|
||||
}
|
||||
|
||||
/// Actor that replies with its inbox address.
|
||||
pub struct InboxReplyActor;
|
||||
|
||||
impl ActorInterface for InboxReplyActor {
|
||||
type Incoming = Ping;
|
||||
type Response = Pong;
|
||||
fn handle(&mut self, ctx: &Ctx, msg: Ping) {
|
||||
let _ = ctx.send(msg.reply_to, Pong);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Helper: construct a Runtime with StdExtension installed.
|
||||
pub fn std_runtime(config: RuntimeConfig) -> Runtime {
|
||||
Runtime::new(config).with_extension(Arc::new(StdExtension::new()))
|
||||
}
|
||||
|
||||
/// Tick up to `max` times, returning as soon as `inbox` has a message.
|
||||
pub fn tick_until_recv<M: swactor::actor::Message>(
|
||||
rt: &Runtime,
|
||||
inbox: &Inbox<M>,
|
||||
max: usize,
|
||||
) -> Option<M> {
|
||||
for _ in 0..max {
|
||||
rt.tick();
|
||||
if let Some(msg) = inbox.try_recv() {
|
||||
return Some(msg);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Tick `n` times, then drain all messages from the inbox.
|
||||
pub fn tick_and_drain<M: swactor::actor::Message>(
|
||||
rt: &Runtime,
|
||||
inbox: &Inbox<M>,
|
||||
ticks: usize,
|
||||
) -> Vec<M> {
|
||||
for _ in 0..ticks {
|
||||
rt.tick();
|
||||
}
|
||||
std::iter::from_fn(|| inbox.try_recv()).collect()
|
||||
}
|
||||
|
|
@ -8,9 +8,12 @@ use std::collections::HashMap;
|
|||
use proptest::prelude::*;
|
||||
use proptest_state_machine::{prop_state_machine, ReferenceStateMachine, StateMachineTest};
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use swactor::actor::{ActorAddress, ActorInterface};
|
||||
use swactor::config::{MailboxOverflow, RuntimeConfig};
|
||||
use swactor::runtime::{Ctx, Inbox, Runtime};
|
||||
use swactor_std::{CtxTimers, StdExtension};
|
||||
|
||||
// ─── Shared Actor Types ────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -141,7 +144,8 @@ proptest! {
|
|||
/// One-shot timer fires at exactly the right tick for any delay.
|
||||
#[test]
|
||||
fn one_shot_timer_fires_at_correct_tick(delay in 1u64..20) {
|
||||
let rt = Runtime::new(RuntimeConfig::default());
|
||||
let rt = Runtime::new(RuntimeConfig::default())
|
||||
.with_extension(Arc::new(StdExtension::new()));
|
||||
let inbox = rt.new_inbox::<Ping>().unwrap();
|
||||
|
||||
struct TimerActor { target: ActorAddress, delay: u64 }
|
||||
|
|
@ -175,7 +179,8 @@ proptest! {
|
|||
/// Interval timer fires at correct periodic ticks for any period.
|
||||
#[test]
|
||||
fn interval_timer_fires_at_correct_period(period in 1u64..10) {
|
||||
let rt = Runtime::new(RuntimeConfig::default());
|
||||
let rt = Runtime::new(RuntimeConfig::default())
|
||||
.with_extension(Arc::new(StdExtension::new()));
|
||||
let inbox = rt.new_inbox::<Ping>().unwrap();
|
||||
|
||||
struct IntervalActor { target: ActorAddress, period: u64 }
|
||||
|
|
|
|||
4622
tests/runtime_api.rs
4622
tests/runtime_api.rs
File diff suppressed because it is too large
Load diff
224
tests/runtime_hasher.rs
Normal file
224
tests/runtime_hasher.rs
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
mod common;
|
||||
use common::*;
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
// ── Identity Hasher Correctness ────────────────────────────────────────────
|
||||
|
||||
/// Given: 200 actors each expecting a unique numbered message
|
||||
/// When: Each actor receives its number and replies with (self_addr, number)
|
||||
/// Then: All 200 replies match -- no message was misrouted by the identity hasher
|
||||
#[test]
|
||||
fn many_actors_all_receive_correct_messages() {
|
||||
#[derive(Clone)]
|
||||
struct NumberedMsg {
|
||||
n: usize,
|
||||
reply_to: ActorAddress,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct NumberedReply {
|
||||
from: ActorAddress,
|
||||
n: usize,
|
||||
}
|
||||
|
||||
struct NumberedActor;
|
||||
|
||||
impl ActorInterface for NumberedActor {
|
||||
type Incoming = NumberedMsg;
|
||||
type Response = ();
|
||||
fn handle(&mut self, ctx: &Ctx, msg: NumberedMsg) {
|
||||
let _ = ctx.send(
|
||||
msg.reply_to,
|
||||
NumberedReply {
|
||||
from: ctx.self_addr(),
|
||||
n: msg.n,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let rt = std_runtime(RuntimeConfig {
|
||||
max_actors: 300,
|
||||
channel_buffer_size: 1024,
|
||||
num_threads: 1,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let inbox = rt.new_inbox::<NumberedReply>().unwrap();
|
||||
let inbox_addr = *inbox.addr();
|
||||
|
||||
// Spawn 200 actors
|
||||
let mut addrs = Vec::new();
|
||||
for _ in 0..200 {
|
||||
addrs.push(rt.spawn(NumberedActor).unwrap());
|
||||
}
|
||||
rt.tick(); // on_start
|
||||
|
||||
// Send unique numbered message to each
|
||||
for (i, addr) in addrs.iter().enumerate() {
|
||||
rt.send_to(
|
||||
*addr,
|
||||
NumberedMsg {
|
||||
n: i,
|
||||
reply_to: inbox_addr,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
rt.tick(); // process + reply
|
||||
rt.tick(); // deliver replies
|
||||
|
||||
// Verify all 200 replies
|
||||
let mut replies: Vec<NumberedReply> = Vec::new();
|
||||
while let Some(reply) = inbox.try_recv() {
|
||||
replies.push(reply);
|
||||
}
|
||||
|
||||
assert_eq!(replies.len(), 200, "should receive exactly 200 replies");
|
||||
|
||||
// Verify each reply came from the correct actor with the correct number
|
||||
for (i, addr) in addrs.iter().enumerate() {
|
||||
let reply = replies.iter().find(|r| r.n == i);
|
||||
assert!(
|
||||
reply.is_some(),
|
||||
"missing reply for actor #{i}"
|
||||
);
|
||||
assert_eq!(
|
||||
reply.unwrap().from, *addr,
|
||||
"reply #{i} came from wrong actor"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Given: A 100-actor ring where each actor forwards to the next
|
||||
/// When: A message enters the ring and traverses all 100 hops
|
||||
/// Then: The message completes the full circuit (address_map lookups all correct)
|
||||
#[test]
|
||||
fn ring_routing_unchanged_after_hasher_optimization() {
|
||||
#[derive(Clone)]
|
||||
struct RingHop {
|
||||
hops_remaining: usize,
|
||||
final_dest: ActorAddress,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct RingDone(usize); // total hops completed
|
||||
|
||||
struct RingNode {
|
||||
next: ActorAddress,
|
||||
}
|
||||
|
||||
impl ActorInterface for RingNode {
|
||||
type Incoming = RingHop;
|
||||
type Response = ();
|
||||
fn handle(&mut self, ctx: &Ctx, msg: RingHop) {
|
||||
if msg.hops_remaining == 0 {
|
||||
let _ = ctx.send(msg.final_dest, RingDone(100));
|
||||
} else {
|
||||
let _ = ctx.send(
|
||||
self.next,
|
||||
RingHop {
|
||||
hops_remaining: msg.hops_remaining - 1,
|
||||
final_dest: msg.final_dest,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let rt = std_runtime(RuntimeConfig {
|
||||
max_actors: 200,
|
||||
channel_buffer_size: 1024,
|
||||
num_threads: 1,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let inbox = rt.new_inbox::<RingDone>().unwrap();
|
||||
let inbox_addr = *inbox.addr();
|
||||
|
||||
// Build chain backwards: last node sends to inbox, first node receives
|
||||
let mut addrs = Vec::new();
|
||||
let mut next = inbox_addr;
|
||||
for _ in (0..100).rev() {
|
||||
let node = RingNode { next };
|
||||
let addr = rt.spawn(node).unwrap();
|
||||
addrs.push(addr);
|
||||
next = addr;
|
||||
}
|
||||
addrs.reverse(); // addrs[0] is start of chain
|
||||
|
||||
rt.tick(); // on_start
|
||||
|
||||
// Inject message at the start
|
||||
rt.send_to(
|
||||
addrs[0],
|
||||
RingHop {
|
||||
hops_remaining: 99,
|
||||
final_dest: inbox_addr,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Tick enough times for the message to traverse all 100 actors
|
||||
for _ in 0..110 {
|
||||
rt.tick();
|
||||
}
|
||||
|
||||
let result = inbox.try_recv();
|
||||
assert!(result.is_some(), "ring message should complete all 100 hops");
|
||||
assert_eq!(result.unwrap(), RingDone(100));
|
||||
}
|
||||
|
||||
/// Given: An actor that calls ctx.stop_self() upon receiving a trigger message
|
||||
/// When: The trigger is sent, then 5 more messages are sent, then ticked
|
||||
/// Then: The actor is removed, only messages before stop are processed
|
||||
#[test]
|
||||
fn stop_self_with_pending_messages_still_works() {
|
||||
let processed = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Msg(bool); // true = trigger stop
|
||||
|
||||
struct StopOnTrigger(Arc<AtomicUsize>);
|
||||
|
||||
impl ActorInterface for StopOnTrigger {
|
||||
type Incoming = Msg;
|
||||
type Response = ();
|
||||
fn handle(&mut self, ctx: &Ctx, msg: Msg) {
|
||||
self.0.fetch_add(1, Ordering::Relaxed);
|
||||
if msg.0 {
|
||||
ctx.stop_self();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let p = processed.clone();
|
||||
let addr = rt.spawn(StopOnTrigger(p)).unwrap();
|
||||
rt.tick(); // on_start
|
||||
|
||||
// Send: 2 normal, 1 trigger, 5 more normal
|
||||
rt.send_to(addr, Msg(false)).unwrap();
|
||||
rt.send_to(addr, Msg(false)).unwrap();
|
||||
rt.send_to(addr, Msg(true)).unwrap(); // stop trigger
|
||||
rt.send_to(addr, Msg(false)).unwrap();
|
||||
rt.send_to(addr, Msg(false)).unwrap();
|
||||
rt.send_to(addr, Msg(false)).unwrap();
|
||||
rt.send_to(addr, Msg(false)).unwrap();
|
||||
rt.send_to(addr, Msg(false)).unwrap();
|
||||
|
||||
rt.tick(); // process messages -- stops after trigger
|
||||
rt.tick(); // cleanup
|
||||
|
||||
// Only 3 messages should be processed (2 normal + 1 trigger)
|
||||
assert_eq!(
|
||||
processed.load(Ordering::Relaxed),
|
||||
3,
|
||||
"should process exactly the messages up to and including the stop trigger"
|
||||
);
|
||||
|
||||
// Subsequent sends should fail
|
||||
assert!(rt.send_to(addr, Msg(false)).is_err());
|
||||
}
|
||||
454
tests/runtime_hooks.rs
Normal file
454
tests/runtime_hooks.rs
Normal file
|
|
@ -0,0 +1,454 @@
|
|||
mod common;
|
||||
use common::*;
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
// ── Lifecycle Hook Helpers ────────────────────────────────────────────────
|
||||
|
||||
/// An actor that records lifecycle events to shared counters.
|
||||
struct LifecycleActor {
|
||||
started: Arc<AtomicUsize>,
|
||||
stopped: Arc<AtomicUsize>,
|
||||
handled: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl ActorInterface for LifecycleActor {
|
||||
type Incoming = Ping;
|
||||
type Response = Pong;
|
||||
|
||||
fn on_start(&mut self, _ctx: &Ctx) {
|
||||
self.started.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn on_stop(&mut self, _ctx: &Ctx) {
|
||||
self.stopped.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn handle(&mut self, ctx: &Ctx, msg: Ping) {
|
||||
self.handled.fetch_add(1, Ordering::Relaxed);
|
||||
let _ = ctx.send(msg.reply_to, Pong);
|
||||
}
|
||||
}
|
||||
|
||||
/// An actor that stops itself after processing N messages.
|
||||
struct SelfStopActor {
|
||||
count: usize,
|
||||
stop_after: usize,
|
||||
stopped: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl ActorInterface for SelfStopActor {
|
||||
type Incoming = Forward;
|
||||
type Response = Done;
|
||||
|
||||
fn on_stop(&mut self, _ctx: &Ctx) {
|
||||
self.stopped.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn handle(&mut self, ctx: &Ctx, msg: Forward) {
|
||||
self.count += 1;
|
||||
let _ = ctx.send(msg.reply_to, Done(msg.value));
|
||||
if self.count >= self.stop_after {
|
||||
ctx.stop_self();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An actor that sends a farewell message in on_stop.
|
||||
struct FarewellActor {
|
||||
farewell_to: ActorAddress,
|
||||
}
|
||||
|
||||
impl ActorInterface for FarewellActor {
|
||||
type Incoming = Ping;
|
||||
type Response = Pong;
|
||||
|
||||
fn on_stop(&mut self, ctx: &Ctx) {
|
||||
let _ = ctx.send(self.farewell_to, Pong);
|
||||
}
|
||||
|
||||
fn handle(&mut self, ctx: &Ctx, msg: Ping) {
|
||||
let _ = ctx.send(msg.reply_to, Pong);
|
||||
}
|
||||
}
|
||||
|
||||
/// An actor whose on_start panics.
|
||||
struct PanicOnStartActor {
|
||||
handled: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl ActorInterface for PanicOnStartActor {
|
||||
type Incoming = Ping;
|
||||
type Response = Pong;
|
||||
|
||||
fn on_start(&mut self, _ctx: &Ctx) {
|
||||
panic!("on_start panic");
|
||||
}
|
||||
|
||||
fn handle(&mut self, _ctx: &Ctx, _msg: Ping) {
|
||||
self.handled.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles Forward messages, replies Done(value * 2), panics on the panic_at-th message.
|
||||
/// (Needed for on_start_called_again_after_restart and stop_vs_panic tests.)
|
||||
struct RestartTestActor {
|
||||
count: usize,
|
||||
panic_at: usize,
|
||||
}
|
||||
|
||||
impl ActorInterface for RestartTestActor {
|
||||
type Incoming = Forward;
|
||||
type Response = Done;
|
||||
fn handle(&mut self, ctx: &Ctx, msg: Forward) {
|
||||
self.count += 1;
|
||||
if self.count >= self.panic_at {
|
||||
panic!("intentional panic at message {}", self.count);
|
||||
}
|
||||
let _ = ctx.send(msg.reply_to, Done(msg.value * 2));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Lifecycle Hook Tests ──────────────────────────────────────────────────
|
||||
|
||||
/// Given an actor with on_start implemented,
|
||||
/// when it is spawned and the runtime ticks,
|
||||
/// then on_start is called exactly once before the first message.
|
||||
#[test]
|
||||
fn on_start_called_before_first_message() {
|
||||
let started = Arc::new(AtomicUsize::new(0));
|
||||
let stopped = Arc::new(AtomicUsize::new(0));
|
||||
let handled = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
|
||||
let addr = rt.spawn(LifecycleActor {
|
||||
started: started.clone(),
|
||||
stopped: stopped.clone(),
|
||||
handled: handled.clone(),
|
||||
}).unwrap();
|
||||
|
||||
// First tick — should call on_start
|
||||
rt.tick();
|
||||
assert_eq!(started.load(Ordering::Relaxed), 1, "on_start called on first tick");
|
||||
assert_eq!(handled.load(Ordering::Relaxed), 0, "no messages processed yet");
|
||||
|
||||
// Send messages and tick more
|
||||
let _ = rt.send_to(addr, Ping { reply_to: *inbox.addr() });
|
||||
rt.tick();
|
||||
assert_eq!(started.load(Ordering::Relaxed), 1, "on_start not called again");
|
||||
assert_eq!(handled.load(Ordering::Relaxed), 1, "message processed after on_start");
|
||||
}
|
||||
|
||||
/// Given an actor with on_start,
|
||||
/// when multiple actors are spawned,
|
||||
/// then each gets its own on_start call exactly once.
|
||||
#[test]
|
||||
fn on_start_called_per_actor() {
|
||||
let started = Arc::new(AtomicUsize::new(0));
|
||||
let stopped = Arc::new(AtomicUsize::new(0));
|
||||
let handled = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
|
||||
for _ in 0..5 {
|
||||
let _ = rt.spawn(LifecycleActor {
|
||||
started: started.clone(),
|
||||
stopped: stopped.clone(),
|
||||
handled: handled.clone(),
|
||||
}).unwrap();
|
||||
}
|
||||
|
||||
rt.tick();
|
||||
assert_eq!(started.load(Ordering::Relaxed), 5, "on_start called for each of 5 actors");
|
||||
|
||||
// Subsequent ticks don't repeat on_start
|
||||
rt.tick();
|
||||
rt.tick();
|
||||
assert_eq!(started.load(Ordering::Relaxed), 5, "on_start still 5 after more ticks");
|
||||
}
|
||||
|
||||
/// Given an actor whose on_start panics,
|
||||
/// when it is spawned and the runtime ticks,
|
||||
/// then it is immediately poisoned and never processes messages.
|
||||
#[test]
|
||||
fn on_start_panic_poisons_actor() {
|
||||
let handled = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
|
||||
let addr = rt.spawn(PanicOnStartActor { handled: handled.clone() }).unwrap();
|
||||
|
||||
let _ = rt.send_to(addr, Ping { reply_to: *inbox.addr() });
|
||||
for _ in 0..5 { rt.tick(); }
|
||||
|
||||
assert_eq!(handled.load(Ordering::Relaxed), 0, "actor never processed messages");
|
||||
|
||||
let stats = rt.stats();
|
||||
let total_panics: u64 = stats.workers.iter().map(|w| w.panics).sum();
|
||||
assert_eq!(total_panics, 1, "on_start panic counted");
|
||||
}
|
||||
|
||||
// ── Graceful Stop Tests ───────────────────────────────────────────────────
|
||||
|
||||
/// Given an actor that calls ctx.stop_self() after 3 messages,
|
||||
/// when 5 messages are sent,
|
||||
/// then only 3 are processed, the actor is removed, and on_stop is called.
|
||||
#[test]
|
||||
fn actor_can_stop_self() {
|
||||
let stopped = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Done>().unwrap();
|
||||
|
||||
let addr = rt.spawn(SelfStopActor {
|
||||
count: 0,
|
||||
stop_after: 3,
|
||||
stopped: stopped.clone(),
|
||||
}).unwrap();
|
||||
|
||||
for i in 0..5 {
|
||||
let _ = rt.send_to(addr, Forward { value: i, reply_to: *inbox.addr() });
|
||||
}
|
||||
for _ in 0..10 { rt.tick(); }
|
||||
|
||||
// Only 3 messages should be processed (stop_self after 3rd)
|
||||
let mut replies = Vec::new();
|
||||
while let Some(Done(v)) = inbox.try_recv() {
|
||||
replies.push(v);
|
||||
}
|
||||
assert_eq!(replies.len(), 3, "only 3 messages processed before stop");
|
||||
assert!(replies.contains(&0));
|
||||
assert!(replies.contains(&1));
|
||||
assert!(replies.contains(&2));
|
||||
|
||||
assert_eq!(stopped.load(Ordering::Relaxed), 1, "on_stop called exactly once");
|
||||
|
||||
// Actor should be removed from address map
|
||||
let stats = rt.stats();
|
||||
assert_eq!(stats.actors.len(), 0, "stopped actor removed from address map");
|
||||
}
|
||||
|
||||
/// Given a running actor,
|
||||
/// when runtime.stop_actor(addr) is called,
|
||||
/// then the actor stops, on_stop is called, and it's removed from the pool.
|
||||
#[test]
|
||||
fn runtime_can_stop_actor() {
|
||||
let started = Arc::new(AtomicUsize::new(0));
|
||||
let stopped = Arc::new(AtomicUsize::new(0));
|
||||
let handled = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
|
||||
let addr = rt.spawn(LifecycleActor {
|
||||
started: started.clone(),
|
||||
stopped: stopped.clone(),
|
||||
handled: handled.clone(),
|
||||
}).unwrap();
|
||||
|
||||
// Let it start and process a message
|
||||
let _ = rt.send_to(addr, Ping { reply_to: *inbox.addr() });
|
||||
for _ in 0..3 { rt.tick(); }
|
||||
assert_eq!(handled.load(Ordering::Relaxed), 1);
|
||||
|
||||
// Stop it externally
|
||||
rt.stop_actor(addr).unwrap();
|
||||
for _ in 0..3 { rt.tick(); }
|
||||
|
||||
assert_eq!(stopped.load(Ordering::Relaxed), 1, "on_stop called");
|
||||
|
||||
// Actor should be gone
|
||||
let stats = rt.stats();
|
||||
assert_eq!(stats.actors.len(), 0, "stopped actor removed");
|
||||
assert_eq!(stats.workers[0].num_actors, 0);
|
||||
}
|
||||
|
||||
/// Given a stopped actor,
|
||||
/// when new messages are sent to it,
|
||||
/// then sends return Err (address not found).
|
||||
#[test]
|
||||
fn send_to_stopped_actor_returns_error() {
|
||||
let stopped = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Done>().unwrap();
|
||||
|
||||
let addr = rt.spawn(SelfStopActor {
|
||||
count: 0,
|
||||
stop_after: 1,
|
||||
stopped: stopped.clone(),
|
||||
}).unwrap();
|
||||
|
||||
// One message triggers stop
|
||||
let _ = rt.send_to(addr, Forward { value: 1, reply_to: *inbox.addr() });
|
||||
for _ in 0..10 { rt.tick(); }
|
||||
|
||||
// Actor is now removed — send should fail
|
||||
let result = rt.send_to(addr, Forward { value: 2, reply_to: *inbox.addr() });
|
||||
assert!(result.is_err(), "send to stopped actor should return Err");
|
||||
}
|
||||
|
||||
/// Given a gracefully stopped actor and a panicked actor,
|
||||
/// then stats.stops and stats.panics track them separately.
|
||||
#[test]
|
||||
fn stop_vs_panic_tracked_separately_in_stats() {
|
||||
let stopped = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Done>().unwrap();
|
||||
|
||||
// Actor that stops itself after 1 message
|
||||
let _stop_addr = rt.spawn(SelfStopActor {
|
||||
count: 0,
|
||||
stop_after: 1,
|
||||
stopped: stopped.clone(),
|
||||
}).unwrap();
|
||||
|
||||
// Actor that panics on first message
|
||||
let panic_addr = rt.spawn(RestartTestActor { count: 0, panic_at: 1 }).unwrap();
|
||||
|
||||
let _ = rt.send_to(_stop_addr, Forward { value: 1, reply_to: *inbox.addr() });
|
||||
let _ = rt.send_to(panic_addr, Forward { value: 1, reply_to: *inbox.addr() });
|
||||
for _ in 0..10 { rt.tick(); }
|
||||
|
||||
let stats = rt.stats();
|
||||
let total_stops: u64 = stats.workers.iter().map(|w| w.stops).sum();
|
||||
let total_panics: u64 = stats.workers.iter().map(|w| w.panics).sum();
|
||||
|
||||
assert_eq!(total_stops, 1, "one graceful stop");
|
||||
assert_eq!(total_panics, 1, "one panic");
|
||||
}
|
||||
|
||||
/// Given an actor with on_stop that sends a farewell message,
|
||||
/// when the actor is stopped,
|
||||
/// then the farewell message is delivered.
|
||||
#[test]
|
||||
fn on_stop_can_send_messages() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
|
||||
let addr = rt.spawn(FarewellActor {
|
||||
farewell_to: *inbox.addr(),
|
||||
}).unwrap();
|
||||
|
||||
// Let it start
|
||||
rt.tick();
|
||||
|
||||
// Stop it
|
||||
rt.stop_actor(addr).unwrap();
|
||||
for _ in 0..5 { rt.tick(); }
|
||||
|
||||
// Should receive farewell Pong from on_stop
|
||||
let farewell = inbox.try_recv();
|
||||
assert_eq!(farewell, Some(Pong), "farewell message delivered from on_stop");
|
||||
}
|
||||
|
||||
/// Given a supervisor with a child that panics and is restarted,
|
||||
/// when the child is respawned by the supervisor,
|
||||
/// then on_start is called again on the fresh instance.
|
||||
#[test]
|
||||
fn on_start_called_again_after_restart() {
|
||||
let started = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
|
||||
let started_c = started.clone();
|
||||
let _sup_addr = rt.spawn(Supervisor::new(
|
||||
SupervisorStrategy::OneForOne,
|
||||
5,
|
||||
vec![ChildSpec::new("child", RestartPolicy::Permanent, move |ctx| {
|
||||
ctx.spawn(LifecycleActor {
|
||||
started: started_c.clone(),
|
||||
stopped: Arc::new(AtomicUsize::new(0)),
|
||||
handled: Arc::new(AtomicUsize::new(0)),
|
||||
})
|
||||
})],
|
||||
)).unwrap();
|
||||
|
||||
// First tick: supervisor starts, spawns child, on_start called
|
||||
for _ in 0..3 { rt.tick(); }
|
||||
assert_eq!(started.load(Ordering::Relaxed), 1, "on_start called once");
|
||||
}
|
||||
|
||||
/// Given an actor stopped via stop_actor() with messages already queued,
|
||||
/// when the stop signal arrives after the queued messages (PoisonPill semantics),
|
||||
/// then messages ahead of the signal are processed, then the actor stops.
|
||||
#[test]
|
||||
fn external_stop_is_queued_after_pending_messages() {
|
||||
let stopped = Arc::new(AtomicUsize::new(0));
|
||||
let handled = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
|
||||
let started = Arc::new(AtomicUsize::new(0));
|
||||
let addr = rt.spawn(LifecycleActor {
|
||||
started: started.clone(),
|
||||
stopped: stopped.clone(),
|
||||
handled: handled.clone(),
|
||||
}).unwrap();
|
||||
|
||||
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
|
||||
// Queue 10 messages, then stop — StopSignal is queued AFTER the 10
|
||||
for _ in 0..10 {
|
||||
let _ = rt.send_to(addr, Ping { reply_to: *inbox.addr() });
|
||||
}
|
||||
rt.stop_actor(addr).unwrap();
|
||||
for _ in 0..10 { rt.tick(); }
|
||||
|
||||
// All 10 messages processed (they were ahead of StopSignal in the queue)
|
||||
let total_handled = handled.load(Ordering::Relaxed);
|
||||
assert_eq!(total_handled, 10, "all messages processed before stop signal");
|
||||
assert_eq!(stopped.load(Ordering::Relaxed), 1, "on_stop called");
|
||||
|
||||
// Actor is removed
|
||||
let stats = rt.stats();
|
||||
assert_eq!(stats.actors.len(), 0, "stopped actor removed");
|
||||
}
|
||||
|
||||
/// Given a running actor with no pending messages,
|
||||
/// when stop_actor() is called and then new messages are sent,
|
||||
/// then the stop takes priority and new messages are not processed.
|
||||
#[test]
|
||||
fn external_stop_before_new_messages_prevents_processing() {
|
||||
let stopped = Arc::new(AtomicUsize::new(0));
|
||||
let handled = Arc::new(AtomicUsize::new(0));
|
||||
let started = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
|
||||
let addr = rt.spawn(LifecycleActor {
|
||||
started: started.clone(),
|
||||
stopped: stopped.clone(),
|
||||
handled: handled.clone(),
|
||||
}).unwrap();
|
||||
|
||||
// Let actor start
|
||||
rt.tick();
|
||||
|
||||
// Stop first, then send messages
|
||||
rt.stop_actor(addr).unwrap();
|
||||
for _ in 0..5 {
|
||||
let _ = rt.send_to(addr, Ping { reply_to: *inbox.addr() });
|
||||
}
|
||||
for _ in 0..10 { rt.tick(); }
|
||||
|
||||
// Stop signal was first in queue, so no messages processed
|
||||
assert_eq!(handled.load(Ordering::Relaxed), 0, "no messages processed after stop");
|
||||
assert_eq!(stopped.load(Ordering::Relaxed), 1, "on_stop called");
|
||||
}
|
||||
|
||||
/// Given stop_actor is called on a nonexistent address,
|
||||
/// then it returns Err.
|
||||
#[test]
|
||||
fn stop_nonexistent_actor_returns_error() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let fake_addr = swactor::actor::ActorAddress::default();
|
||||
let result = rt.stop_actor(fake_addr);
|
||||
assert!(result.is_err(), "stop_actor on nonexistent address should return Err");
|
||||
}
|
||||
1496
tests/runtime_lifecycle.rs
Normal file
1496
tests/runtime_lifecycle.rs
Normal file
File diff suppressed because it is too large
Load diff
362
tests/runtime_mechanics.rs
Normal file
362
tests/runtime_mechanics.rs
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
mod common;
|
||||
use common::*;
|
||||
|
||||
// ── Load-Aware Placement Tests ─────────────────────────────────────────────
|
||||
|
||||
/// Given a multi-threaded runtime where one worker has many more actors,
|
||||
/// when new actors are spawned after a few ticks (so stats propagate),
|
||||
/// then they should be placed on the lighter worker.
|
||||
#[test]
|
||||
fn load_aware_placement_prefers_lighter_worker() {
|
||||
// 2 threads: intentionally imbalance by spawning many actors first
|
||||
let rt = std_runtime(RuntimeConfig {
|
||||
num_threads: 2,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Phase 1: Spawn 20 actors. With round-robin, they split ~10/10.
|
||||
let mut addrs = Vec::new();
|
||||
for _ in 0..20 {
|
||||
addrs.push(rt.spawn(CounterActor { count: 0 }).unwrap());
|
||||
}
|
||||
|
||||
// Run so stats propagate, then bombard worker 0's actors with messages
|
||||
// to create mailbox depth imbalance.
|
||||
let handle = rt.run().unwrap();
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
|
||||
// Send 500 messages to the first 10 actors (likely on worker 0).
|
||||
for addr in &addrs[..10] {
|
||||
for _ in 0..50 {
|
||||
let _ = handle.runtime.send_to(*addr, Increment {
|
||||
reply_to: *addr, // self-reply to keep mailbox depth up
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
|
||||
// Phase 2: Spawn 10 more actors. With load-aware placement,
|
||||
// they should bias toward the lighter worker.
|
||||
let mut late_addrs = Vec::new();
|
||||
for _ in 0..10 {
|
||||
late_addrs.push(handle.runtime.spawn(CounterActor { count: 0 }).unwrap());
|
||||
}
|
||||
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
|
||||
let stats = handle.runtime.stats();
|
||||
handle.shutdown();
|
||||
handle.join();
|
||||
|
||||
// Verify the system is operational — both workers should have actors
|
||||
let total_actors: usize = stats.workers.iter().map(|w| w.num_actors).sum();
|
||||
assert!(total_actors >= 20, "expected at least 20 actors, got {}", total_actors);
|
||||
|
||||
// The lighter worker should have gotten more of the late actors.
|
||||
assert!(
|
||||
stats.workers.iter().all(|w| w.num_actors > 0),
|
||||
"both workers should have actors, got {:?}",
|
||||
stats.workers.iter().map(|w| w.num_actors).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
/// Given a single-threaded runtime (1 worker),
|
||||
/// when many actors are spawned,
|
||||
/// then all go to worker 0 regardless of load (no panic, no error).
|
||||
#[test]
|
||||
fn load_aware_placement_single_worker_degrades_gracefully() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
|
||||
for _ in 0..50 {
|
||||
rt.spawn(CounterActor { count: 0 }).unwrap();
|
||||
}
|
||||
|
||||
// Tick several times to let stats update
|
||||
for _ in 0..10 {
|
||||
rt.tick();
|
||||
}
|
||||
|
||||
let stats = rt.stats();
|
||||
assert_eq!(stats.workers.len(), 1);
|
||||
assert_eq!(stats.workers[0].num_actors, 50);
|
||||
}
|
||||
|
||||
/// Given a fresh runtime with no prior ticks,
|
||||
/// when actors are spawned in a burst,
|
||||
/// then they distribute evenly (round-robin fallback when stats are all zero).
|
||||
#[test]
|
||||
fn load_aware_placement_falls_back_to_round_robin_on_fresh_runtime() {
|
||||
let rt = std_runtime(RuntimeConfig {
|
||||
num_threads: 4,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Spawn 100 actors before any ticks (all stats are zero)
|
||||
for _ in 0..100 {
|
||||
rt.spawn(CounterActor { count: 0 }).unwrap();
|
||||
}
|
||||
|
||||
let handle = rt.run().unwrap();
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
|
||||
let stats = handle.runtime.stats();
|
||||
handle.shutdown();
|
||||
handle.join();
|
||||
|
||||
// With 4 workers and 100 actors, each should have ~25 (+-5).
|
||||
for w in &stats.workers {
|
||||
assert!(
|
||||
w.num_actors >= 20 && w.num_actors <= 30,
|
||||
"worker {} has {} actors, expected ~25 (round-robin)",
|
||||
w.id, w.num_actors
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mailbox Backpressure Tests ─────────────────────────────────────────────
|
||||
|
||||
/// Given a runtime with bounded mailboxes (capacity=10, DropNewest),
|
||||
/// when 50 messages are sent to an actor before any ticks,
|
||||
/// then only the first 10 are delivered and the rest are dropped.
|
||||
#[test]
|
||||
fn bounded_mailbox_drop_newest_caps_at_capacity() {
|
||||
let rt = std_runtime(RuntimeConfig {
|
||||
default_mailbox_capacity: 10,
|
||||
mailbox_overflow: MailboxOverflow::DropNewest,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let inbox = rt.new_inbox::<Count>().unwrap();
|
||||
let addr = rt.spawn(CounterActor { count: 0 }).unwrap();
|
||||
|
||||
// Send 50 messages — only first 10 should be queued
|
||||
for _ in 0..50 {
|
||||
let _ = rt.send_to(addr, Increment { reply_to: *inbox.addr() });
|
||||
}
|
||||
|
||||
// Tick enough times to process all queued messages
|
||||
for _ in 0..20 {
|
||||
rt.tick();
|
||||
}
|
||||
|
||||
// Count replies — should be exactly 10 (the mailbox capacity)
|
||||
let mut replies = 0;
|
||||
while inbox.try_recv().is_some() {
|
||||
replies += 1;
|
||||
}
|
||||
assert_eq!(replies, 10, "should deliver exactly mailbox_capacity messages");
|
||||
|
||||
// Stats should show drops
|
||||
let stats = rt.stats();
|
||||
let total_drops: u64 = stats.workers.iter().map(|w| w.messages_dropped).sum();
|
||||
assert_eq!(total_drops, 40, "40 messages should have been dropped");
|
||||
}
|
||||
|
||||
/// Given a runtime with bounded mailboxes (capacity=5, DropOldest),
|
||||
/// when 10 messages are sent before any tick,
|
||||
/// then only the 5 most recent messages are delivered.
|
||||
#[test]
|
||||
fn bounded_mailbox_drop_oldest_keeps_newest() {
|
||||
let rt = std_runtime(RuntimeConfig {
|
||||
default_mailbox_capacity: 5,
|
||||
mailbox_overflow: MailboxOverflow::DropOldest,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let inbox = rt.new_inbox::<Done>().unwrap();
|
||||
let addr = rt.spawn(DoubleActor).unwrap();
|
||||
|
||||
// Send messages with values 0..10. DoubleActor replies Done(value * 2).
|
||||
for i in 0..10 {
|
||||
let _ = rt.send_to(addr, Forward {
|
||||
value: i,
|
||||
reply_to: *inbox.addr(),
|
||||
});
|
||||
}
|
||||
|
||||
for _ in 0..10 {
|
||||
rt.tick();
|
||||
}
|
||||
|
||||
// Collect all replies
|
||||
let mut replies = Vec::new();
|
||||
while let Some(Done(v)) = inbox.try_recv() {
|
||||
replies.push(v);
|
||||
}
|
||||
|
||||
assert_eq!(replies.len(), 5, "should deliver exactly 5 messages");
|
||||
// The 5 most recent: values 5,6,7,8,9 -> doubled: 10,12,14,16,18
|
||||
assert_eq!(replies, vec![10, 12, 14, 16, 18], "should keep the newest messages");
|
||||
}
|
||||
|
||||
/// Given a runtime with unbounded mailboxes (capacity=0, the default),
|
||||
/// when many messages are sent,
|
||||
/// then all are delivered (backward compatibility).
|
||||
#[test]
|
||||
fn unbounded_mailbox_delivers_all_messages() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
|
||||
let inbox = rt.new_inbox::<Count>().unwrap();
|
||||
let addr = rt.spawn(CounterActor { count: 0 }).unwrap();
|
||||
|
||||
for _ in 0..200 {
|
||||
let _ = rt.send_to(addr, Increment { reply_to: *inbox.addr() });
|
||||
}
|
||||
|
||||
for _ in 0..50 {
|
||||
rt.tick();
|
||||
}
|
||||
|
||||
let mut replies = 0;
|
||||
while inbox.try_recv().is_some() {
|
||||
replies += 1;
|
||||
}
|
||||
assert_eq!(replies, 200, "all 200 messages should be delivered with unbounded mailbox");
|
||||
|
||||
let stats = rt.stats();
|
||||
let total_drops: u64 = stats.workers.iter().map(|w| w.messages_dropped).sum();
|
||||
assert_eq!(total_drops, 0, "no drops with unbounded mailbox");
|
||||
}
|
||||
|
||||
/// Given bounded mailboxes with budget, when an actor processes messages
|
||||
/// and frees mailbox space, then new messages should be accepted on subsequent ticks.
|
||||
#[test]
|
||||
fn bounded_mailbox_refills_after_processing() {
|
||||
let rt = std_runtime(RuntimeConfig {
|
||||
default_mailbox_capacity: 5,
|
||||
actor_message_budget: 5,
|
||||
mailbox_overflow: MailboxOverflow::DropNewest,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let inbox = rt.new_inbox::<Count>().unwrap();
|
||||
let addr = rt.spawn(CounterActor { count: 0 }).unwrap();
|
||||
|
||||
// Send first batch of 5 — fills mailbox exactly
|
||||
for _ in 0..5 {
|
||||
let _ = rt.send_to(addr, Increment { reply_to: *inbox.addr() });
|
||||
}
|
||||
|
||||
// Tick to process all 5 (budget=5, capacity=5)
|
||||
rt.tick();
|
||||
|
||||
// Send second batch of 5 — mailbox is empty, so all 5 should be accepted
|
||||
for _ in 0..5 {
|
||||
let _ = rt.send_to(addr, Increment { reply_to: *inbox.addr() });
|
||||
}
|
||||
|
||||
rt.tick();
|
||||
|
||||
let mut replies = 0;
|
||||
while inbox.try_recv().is_some() {
|
||||
replies += 1;
|
||||
}
|
||||
assert_eq!(replies, 10, "all 10 messages across 2 batches should be processed");
|
||||
|
||||
let stats = rt.stats();
|
||||
let total_drops: u64 = stats.workers.iter().map(|w| w.messages_dropped).sum();
|
||||
assert_eq!(total_drops, 0, "no drops when mailbox drains between batches");
|
||||
}
|
||||
|
||||
// ── Dead Actor Cleanup Tests ───────────────────────────────────────────────
|
||||
|
||||
/// Given an actor that panics and is poisoned,
|
||||
/// when ticks continue,
|
||||
/// then the actor is removed from stats and sends to its address fail.
|
||||
#[test]
|
||||
fn dead_actor_cleaned_up_from_stats_and_address_map() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
|
||||
let good = rt.spawn(PingPongActor).unwrap();
|
||||
let bad = rt.spawn(PanicActor).unwrap();
|
||||
|
||||
// Trigger panic
|
||||
let _ = rt.send_to(bad, PanicMsg);
|
||||
for _ in 0..5 { rt.tick(); }
|
||||
|
||||
let stats = rt.stats();
|
||||
// Good actor still present, bad actor cleaned up
|
||||
assert_eq!(stats.workers[0].num_actors, 1, "only the healthy actor should remain");
|
||||
assert!(
|
||||
stats.actors.iter().any(|(a, _)| *a == good),
|
||||
"good actor should be in address map"
|
||||
);
|
||||
assert!(
|
||||
!stats.actors.iter().any(|(a, _)| *a == bad),
|
||||
"poisoned actor should be removed from address map"
|
||||
);
|
||||
|
||||
// Sends to cleaned-up actor fail
|
||||
let result = rt.send_to(bad, PanicMsg);
|
||||
assert!(result.is_err(), "send to cleaned-up actor should fail");
|
||||
}
|
||||
|
||||
/// Given many actors that all panic,
|
||||
/// when ticks proceed,
|
||||
/// then all are cleaned up and stats reflect zero actors.
|
||||
#[test]
|
||||
fn bulk_dead_actor_cleanup() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
|
||||
let mut addrs = Vec::new();
|
||||
for _ in 0..20 {
|
||||
addrs.push(rt.spawn(PanicActor).unwrap());
|
||||
}
|
||||
|
||||
// Trigger all panics
|
||||
for &addr in &addrs {
|
||||
let _ = rt.send_to(addr, PanicMsg);
|
||||
}
|
||||
for _ in 0..10 { rt.tick(); }
|
||||
|
||||
let stats = rt.stats();
|
||||
assert_eq!(stats.workers[0].num_actors, 0, "all poisoned actors should be cleaned up");
|
||||
assert_eq!(
|
||||
stats.actors.len(), 0,
|
||||
"address map should be empty after all actors poisoned"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Actor Recovery Helpers ──────────────────────────────────────────────────
|
||||
|
||||
/// Handles Forward messages, replies Done(value * 2), panics on the panic_at-th message.
|
||||
struct RestartTestActor {
|
||||
count: usize,
|
||||
panic_at: usize,
|
||||
}
|
||||
|
||||
impl ActorInterface for RestartTestActor {
|
||||
type Incoming = Forward;
|
||||
type Response = Done;
|
||||
fn handle(&mut self, ctx: &Ctx, msg: Forward) {
|
||||
self.count += 1;
|
||||
if self.count >= self.panic_at {
|
||||
panic!("intentional panic at message {}", self.count);
|
||||
}
|
||||
let _ = ctx.send(msg.reply_to, Done(msg.value * 2));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Actor Recovery Tests ───────────────────────────────────────────────────
|
||||
|
||||
/// Given an actor that panics,
|
||||
/// when it panics,
|
||||
/// then it is poisoned and future messages are discarded.
|
||||
#[test]
|
||||
fn non_restartable_actor_still_poisons_on_panic() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Done>().unwrap();
|
||||
|
||||
// Normal spawn — not restartable
|
||||
let addr = rt.spawn(RestartTestActor { count: 0, panic_at: 1 }).unwrap();
|
||||
|
||||
let _ = rt.send_to(addr, Forward { value: 42, reply_to: *inbox.addr() });
|
||||
for _ in 0..5 { rt.tick(); }
|
||||
|
||||
let stats = rt.stats();
|
||||
let total_panics: u64 = stats.workers.iter().map(|w| w.panics).sum();
|
||||
let total_restarts: u64 = stats.workers.iter().map(|w| w.restarts).sum();
|
||||
assert_eq!(total_panics, 1, "should panic");
|
||||
assert_eq!(total_restarts, 0, "should not restart (not restartable)");
|
||||
}
|
||||
758
tests/runtime_registry.rs
Normal file
758
tests/runtime_registry.rs
Normal file
|
|
@ -0,0 +1,758 @@
|
|||
mod common;
|
||||
use common::*;
|
||||
|
||||
// ── Named Actor Registry ────────────────────────────────────────────────────
|
||||
|
||||
/// Given a named actor is spawned,
|
||||
/// when I look it up by name,
|
||||
/// then I get the same address that spawn returned.
|
||||
#[test]
|
||||
fn named_actor_lookup_returns_spawn_address() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let addr = rt.spawn_named("greeter", PingPongActor).unwrap();
|
||||
assert_eq!(rt.where_is("greeter"), Some(addr));
|
||||
}
|
||||
|
||||
/// Given a named actor exists,
|
||||
/// when I send a message to the looked-up address,
|
||||
/// then the actor receives and processes it.
|
||||
#[test]
|
||||
fn named_actor_receives_messages_via_lookup() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
let addr = rt.spawn_named("ponger", PingPongActor).unwrap();
|
||||
assert_eq!(rt.where_is("ponger"), Some(addr));
|
||||
|
||||
rt.send_to(addr, Ping { reply_to: *inbox.addr() }).unwrap();
|
||||
rt.tick();
|
||||
assert!(inbox.try_recv().is_some(), "named actor should process message");
|
||||
}
|
||||
|
||||
/// Given a name is already registered,
|
||||
/// when I try to spawn another actor with the same name,
|
||||
/// then I get an error and the original binding is preserved.
|
||||
#[test]
|
||||
fn duplicate_name_returns_error() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let first_addr = rt.spawn_named("singleton", PingPongActor).unwrap();
|
||||
let result = rt.spawn_named("singleton", PingPongActor);
|
||||
assert!(result.is_err(), "duplicate name should fail");
|
||||
assert_eq!(rt.where_is("singleton"), Some(first_addr), "original binding preserved");
|
||||
}
|
||||
|
||||
/// Given no actors are registered,
|
||||
/// when I look up a nonexistent name,
|
||||
/// then I get None.
|
||||
#[test]
|
||||
fn where_is_returns_none_for_unknown_name() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
assert_eq!(rt.where_is("ghost"), None);
|
||||
}
|
||||
|
||||
/// Given a named actor is stopped,
|
||||
/// when the next tick runs cleanup,
|
||||
/// then the name is automatically unregistered.
|
||||
#[test]
|
||||
fn name_auto_unregistered_on_actor_death() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let addr = rt.spawn_named("ephemeral", PingPongActor).unwrap();
|
||||
rt.tick(); // on_start
|
||||
|
||||
rt.stop_actor(addr).unwrap();
|
||||
rt.tick(); // process StopSignal + cleanup
|
||||
|
||||
assert_eq!(rt.where_is("ephemeral"), None, "name should be freed after stop");
|
||||
}
|
||||
|
||||
/// Given a named actor died and its name was freed,
|
||||
/// when I spawn a new actor with the same name,
|
||||
/// then registration succeeds with a new address.
|
||||
#[test]
|
||||
fn name_can_be_reused_after_actor_death() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let first = rt.spawn_named("worker", PingPongActor).unwrap();
|
||||
rt.tick();
|
||||
rt.stop_actor(first).unwrap();
|
||||
rt.tick(); // cleanup frees the name
|
||||
|
||||
let second = rt.spawn_named("worker", PingPongActor).unwrap();
|
||||
assert_ne!(first, second, "new actor should have a different address");
|
||||
assert_eq!(rt.where_is("worker"), Some(second));
|
||||
}
|
||||
|
||||
/// Given a named actor panics (and is not restartable),
|
||||
/// when the next tick runs cleanup,
|
||||
/// then the name is freed.
|
||||
#[test]
|
||||
fn name_auto_unregistered_on_panic() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
let _addr = rt.spawn_named("fragile", PanicActor).unwrap();
|
||||
rt.tick(); // on_start
|
||||
|
||||
rt.send_to(_addr, PanicMsg).unwrap();
|
||||
rt.tick(); // panic -> poison -> cleanup
|
||||
|
||||
assert_eq!(rt.where_is("fragile"), None, "name freed after panic");
|
||||
// Can reuse the name
|
||||
let _new = rt.spawn_named("fragile", PingPongActor).unwrap();
|
||||
assert!(rt.where_is("fragile").is_some());
|
||||
drop(inbox);
|
||||
}
|
||||
|
||||
/// Given multiple named actors are registered,
|
||||
/// when I call registered_names(),
|
||||
/// then all names are returned.
|
||||
#[test]
|
||||
fn registered_names_lists_all() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
rt.spawn_named("alpha", PingPongActor).unwrap();
|
||||
rt.spawn_named("beta", PingPongActor).unwrap();
|
||||
rt.spawn_named("gamma", PingPongActor).unwrap();
|
||||
|
||||
let mut names = rt.registered_names();
|
||||
names.sort();
|
||||
assert_eq!(names, vec!["alpha", "beta", "gamma"]);
|
||||
}
|
||||
|
||||
/// Given a named actor exists,
|
||||
/// when I manually unregister the name,
|
||||
/// then the name is freed but the actor continues running.
|
||||
#[test]
|
||||
fn manual_unregister_frees_name_but_actor_lives() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
let addr = rt.spawn_named("temp-name", PingPongActor).unwrap();
|
||||
rt.tick(); // on_start
|
||||
|
||||
let removed = rt.unregister("temp-name");
|
||||
assert_eq!(removed, Some(addr));
|
||||
assert_eq!(rt.where_is("temp-name"), None, "name freed");
|
||||
|
||||
// Actor still alive and can receive messages
|
||||
rt.send_to(addr, Ping { reply_to: *inbox.addr() }).unwrap();
|
||||
rt.tick();
|
||||
assert!(inbox.try_recv().is_some(), "actor still processes messages");
|
||||
}
|
||||
|
||||
/// An actor that looks up a peer by name using ctx.where_is().
|
||||
struct NameLookupActor {
|
||||
target_name: &'static str,
|
||||
reply_to: ActorAddress,
|
||||
}
|
||||
|
||||
impl ActorInterface for NameLookupActor {
|
||||
type Incoming = Ping;
|
||||
type Response = ();
|
||||
fn handle(&mut self, ctx: &Ctx, _msg: Ping) {
|
||||
if let Some(peer) = ctx.where_is(self.target_name) {
|
||||
ctx.send(self.reply_to, MyAddr(peer)).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Given a named actor exists,
|
||||
/// when another actor calls ctx.where_is() from inside a handler,
|
||||
/// then it resolves the correct address.
|
||||
#[test]
|
||||
fn ctx_where_is_resolves_inside_handler() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<MyAddr>().unwrap();
|
||||
let target = rt.spawn_named("target", PingPongActor).unwrap();
|
||||
|
||||
let looker = rt.spawn(NameLookupActor {
|
||||
target_name: "target",
|
||||
reply_to: *inbox.addr(),
|
||||
}).unwrap();
|
||||
|
||||
rt.tick(); // on_start
|
||||
rt.send_to(looker, Ping { reply_to: ActorAddress::default() }).unwrap();
|
||||
rt.tick(); // handle -> where_is -> send
|
||||
rt.tick(); // deliver reply
|
||||
|
||||
let result = inbox.try_recv();
|
||||
assert_eq!(result, Some(MyAddr(target)), "ctx.where_is found the named actor");
|
||||
}
|
||||
|
||||
/// An actor that spawns a named child using ctx.spawn_named().
|
||||
struct NamedSpawnerActor {
|
||||
reply_to: ActorAddress,
|
||||
}
|
||||
|
||||
impl ActorInterface for NamedSpawnerActor {
|
||||
type Incoming = Ping;
|
||||
type Response = ();
|
||||
fn handle(&mut self, ctx: &Ctx, _msg: Ping) {
|
||||
match ctx.spawn_named("child", PingPongActor) {
|
||||
Ok(addr) => { ctx.send(self.reply_to, MyAddr(addr)).unwrap(); }
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Given an actor calls ctx.spawn_named("child", ...),
|
||||
/// when the child is spawned,
|
||||
/// then where_is("child") returns the correct address.
|
||||
#[test]
|
||||
fn ctx_spawn_named_registers_from_handler() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<MyAddr>().unwrap();
|
||||
|
||||
let spawner = rt.spawn(NamedSpawnerActor {
|
||||
reply_to: *inbox.addr(),
|
||||
}).unwrap();
|
||||
|
||||
rt.tick(); // on_start
|
||||
rt.send_to(spawner, Ping { reply_to: ActorAddress::default() }).unwrap();
|
||||
rt.tick(); // handle -> spawn_named
|
||||
rt.tick(); // deliver reply
|
||||
|
||||
let child_addr = inbox.try_recv().expect("should receive child address");
|
||||
assert_eq!(rt.where_is("child"), Some(child_addr.0), "name registered from handler");
|
||||
}
|
||||
|
||||
// ── Actor Monitoring / Death Watch ──────────────────────────────────────────
|
||||
|
||||
/// An actor that monitors a target and forwards Down notifications to a reply address.
|
||||
struct WatcherActor {
|
||||
watch_target: ActorAddress,
|
||||
reply_to: ActorAddress,
|
||||
mref: Option<MonitorRef>,
|
||||
}
|
||||
|
||||
impl ActorInterface for WatcherActor {
|
||||
type Incoming = Down;
|
||||
type Response = ();
|
||||
fn on_start(&mut self, ctx: &Ctx) {
|
||||
self.mref = Some(ctx.monitor(self.watch_target));
|
||||
}
|
||||
fn handle(&mut self, ctx: &Ctx, msg: Down) {
|
||||
// Forward the Down notification to the test inbox
|
||||
ctx.send(self.reply_to, msg).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Given actor A monitors actor B,
|
||||
/// when B is gracefully stopped,
|
||||
/// then A receives a Down { reason: Normal } message.
|
||||
#[test]
|
||||
fn monitor_notifies_on_graceful_stop() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Down>().unwrap();
|
||||
|
||||
let target = rt.spawn(PingPongActor).unwrap();
|
||||
let _watcher = rt.spawn(WatcherActor {
|
||||
watch_target: target,
|
||||
reply_to: *inbox.addr(),
|
||||
mref: None,
|
||||
}).unwrap();
|
||||
|
||||
rt.tick(); // on_start -> watcher sets up monitor
|
||||
rt.stop_actor(target).unwrap();
|
||||
rt.tick(); // target receives StopSignal -> cleanup_dead emits Down
|
||||
rt.tick(); // watcher receives Down -> forwards to inbox
|
||||
|
||||
let down = inbox.try_recv().expect("should receive Down notification");
|
||||
assert_eq!(down.addr, target);
|
||||
assert_eq!(down.reason, StopReason::Normal);
|
||||
}
|
||||
|
||||
/// Given actor A monitors actor B,
|
||||
/// when B panics,
|
||||
/// then A receives a Down { reason: Panicked } message.
|
||||
#[test]
|
||||
fn monitor_notifies_on_panic() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Down>().unwrap();
|
||||
|
||||
let target = rt.spawn(PanicActor).unwrap();
|
||||
let _watcher = rt.spawn(WatcherActor {
|
||||
watch_target: target,
|
||||
reply_to: *inbox.addr(),
|
||||
mref: None,
|
||||
}).unwrap();
|
||||
|
||||
rt.tick(); // on_start
|
||||
rt.send_to(target, PanicMsg).unwrap();
|
||||
rt.tick(); // target panics -> cleanup_dead emits Down
|
||||
rt.tick(); // watcher receives Down -> forwards to inbox
|
||||
|
||||
let down = inbox.try_recv().expect("should receive Down on panic");
|
||||
assert_eq!(down.addr, target);
|
||||
assert_eq!(down.reason, StopReason::Panicked);
|
||||
}
|
||||
|
||||
/// Given two actors both monitor the same target,
|
||||
/// when the target dies,
|
||||
/// then both watchers receive independent Down notifications.
|
||||
#[test]
|
||||
fn multiple_watchers_all_notified() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox1 = rt.new_inbox::<Down>().unwrap();
|
||||
let inbox2 = rt.new_inbox::<Down>().unwrap();
|
||||
|
||||
let target = rt.spawn(PingPongActor).unwrap();
|
||||
rt.spawn(WatcherActor {
|
||||
watch_target: target,
|
||||
reply_to: *inbox1.addr(),
|
||||
mref: None,
|
||||
}).unwrap();
|
||||
rt.spawn(WatcherActor {
|
||||
watch_target: target,
|
||||
reply_to: *inbox2.addr(),
|
||||
mref: None,
|
||||
}).unwrap();
|
||||
|
||||
rt.tick(); // on_start for all
|
||||
rt.stop_actor(target).unwrap();
|
||||
rt.tick(); // cleanup -> Down emitted to both watchers
|
||||
rt.tick(); // watchers forward Down to inboxes
|
||||
|
||||
assert!(inbox1.try_recv().is_some(), "watcher 1 should receive Down");
|
||||
assert!(inbox2.try_recv().is_some(), "watcher 2 should receive Down");
|
||||
}
|
||||
|
||||
/// An actor that demonitors in response to a Ping message.
|
||||
struct DemonitorActor {
|
||||
watch_target: ActorAddress,
|
||||
mref: Option<MonitorRef>,
|
||||
}
|
||||
|
||||
impl ActorInterface for DemonitorActor {
|
||||
type Incoming = Ping;
|
||||
type Response = ();
|
||||
fn on_start(&mut self, ctx: &Ctx) {
|
||||
self.mref = Some(ctx.monitor(self.watch_target));
|
||||
}
|
||||
fn handle(&mut self, ctx: &Ctx, _msg: Ping) {
|
||||
// Cancel the monitor
|
||||
if let Some(mref) = self.mref.take() {
|
||||
ctx.demonitor(mref);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Given actor A monitors actor B then demonitors,
|
||||
/// when B dies,
|
||||
/// then A does NOT receive a Down notification.
|
||||
#[test]
|
||||
fn demonitor_cancels_notification() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let down_inbox = rt.new_inbox::<Down>().unwrap();
|
||||
|
||||
let target = rt.spawn(PingPongActor).unwrap();
|
||||
let watcher = rt.spawn(DemonitorActor {
|
||||
watch_target: target,
|
||||
mref: None,
|
||||
}).unwrap();
|
||||
|
||||
rt.tick(); // on_start -> monitor set up
|
||||
|
||||
// Trigger demonitor
|
||||
rt.send_to(watcher, Ping { reply_to: ActorAddress::default() }).unwrap();
|
||||
rt.tick(); // handle -> demonitor
|
||||
|
||||
// Now kill the target
|
||||
rt.stop_actor(target).unwrap();
|
||||
rt.tick(); // cleanup -- no Down should be emitted
|
||||
rt.tick(); // extra tick to be sure
|
||||
|
||||
assert!(down_inbox.try_recv().is_none(), "demonitored -- should NOT receive Down");
|
||||
}
|
||||
|
||||
/// Given actor A monitors B, and A dies before B,
|
||||
/// when B dies,
|
||||
/// then no Down is delivered (dead watcher cleaned up).
|
||||
#[test]
|
||||
fn dead_watcher_does_not_receive_down() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
|
||||
let target = rt.spawn(PingPongActor).unwrap();
|
||||
let watcher = rt.spawn(WatcherActor {
|
||||
watch_target: target,
|
||||
reply_to: ActorAddress::default(), // won't matter, watcher dies first
|
||||
mref: None,
|
||||
}).unwrap();
|
||||
|
||||
rt.tick(); // on_start -> monitor set up
|
||||
rt.stop_actor(watcher).unwrap();
|
||||
rt.tick(); // watcher dies -> its monitors are cleaned up
|
||||
|
||||
// Now kill the target -- the dead watcher's subscription should be gone
|
||||
rt.stop_actor(target).unwrap();
|
||||
rt.tick(); // cleanup -- should not panic or try to deliver to dead watcher
|
||||
// If we get here without panic, the test passes
|
||||
}
|
||||
|
||||
/// Given an external inbox monitors via the runtime,
|
||||
/// when the target dies,
|
||||
/// then the inbox receives a Down message.
|
||||
#[test]
|
||||
fn down_delivered_to_external_inbox() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Down>().unwrap();
|
||||
|
||||
let target = rt.spawn(PingPongActor).unwrap();
|
||||
|
||||
let _watcher = rt.spawn(WatcherActor {
|
||||
watch_target: target,
|
||||
reply_to: *inbox.addr(),
|
||||
mref: None,
|
||||
}).unwrap();
|
||||
|
||||
rt.tick(); // on_start
|
||||
rt.stop_actor(target).unwrap();
|
||||
rt.tick(); // cleanup -> Down to watcher
|
||||
rt.tick(); // watcher forwards to inbox
|
||||
|
||||
let down = inbox.try_recv().expect("inbox should receive forwarded Down");
|
||||
assert_eq!(down.addr, target);
|
||||
assert_eq!(down.reason, StopReason::Normal);
|
||||
}
|
||||
|
||||
/// Given actor A monitors B with two independent monitors,
|
||||
/// when B dies,
|
||||
/// then A receives two Down messages (one per monitor).
|
||||
#[test]
|
||||
fn stacked_monitors_produce_multiple_notifications() {
|
||||
/// An actor that creates two monitors on the same target.
|
||||
struct DoubleWatcherActor {
|
||||
target: ActorAddress,
|
||||
reply_to: ActorAddress,
|
||||
}
|
||||
|
||||
impl ActorInterface for DoubleWatcherActor {
|
||||
type Incoming = Down;
|
||||
type Response = ();
|
||||
fn on_start(&mut self, ctx: &Ctx) {
|
||||
ctx.monitor(self.target);
|
||||
ctx.monitor(self.target);
|
||||
}
|
||||
fn handle(&mut self, ctx: &Ctx, msg: Down) {
|
||||
ctx.send(self.reply_to, msg).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Down>().unwrap();
|
||||
|
||||
let target = rt.spawn(PingPongActor).unwrap();
|
||||
rt.spawn(DoubleWatcherActor {
|
||||
target,
|
||||
reply_to: *inbox.addr(),
|
||||
}).unwrap();
|
||||
|
||||
rt.tick(); // on_start -> 2 monitors
|
||||
rt.stop_actor(target).unwrap();
|
||||
rt.tick(); // cleanup -> 2 Down messages to watcher
|
||||
rt.tick(); // watcher forwards both to inbox
|
||||
|
||||
assert!(inbox.try_recv().is_some(), "first Down");
|
||||
assert!(inbox.try_recv().is_some(), "second Down");
|
||||
assert!(inbox.try_recv().is_none(), "no more");
|
||||
}
|
||||
|
||||
// ── Actor Groups / Pub-Sub ──────────────────────────────────────────────────
|
||||
|
||||
/// Given actors join a group,
|
||||
/// when I query group_members,
|
||||
/// then all joined actors are listed.
|
||||
#[test]
|
||||
fn group_members_returns_joined_actors() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let a = rt.spawn(PingPongActor).unwrap();
|
||||
let b = rt.spawn(PingPongActor).unwrap();
|
||||
|
||||
rt.join_group(a, "workers");
|
||||
rt.join_group(b, "workers");
|
||||
|
||||
let mut members = rt.group_members("workers");
|
||||
members.sort_by_key(|addr| addr.0);
|
||||
let mut expected = vec![a, b];
|
||||
expected.sort_by_key(|addr| addr.0);
|
||||
assert_eq!(members, expected);
|
||||
}
|
||||
|
||||
/// Given no actors have joined a group,
|
||||
/// when I query group_members,
|
||||
/// then the result is empty.
|
||||
#[test]
|
||||
fn empty_group_returns_no_members() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
assert!(rt.group_members("nonexistent").is_empty());
|
||||
}
|
||||
|
||||
/// Given actors in a group,
|
||||
/// when a message is published to the group,
|
||||
/// then all members receive the message.
|
||||
#[test]
|
||||
fn publish_broadcasts_to_all_members() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox1 = rt.new_inbox::<Pong>().unwrap();
|
||||
let inbox2 = rt.new_inbox::<Pong>().unwrap();
|
||||
|
||||
let a = rt.spawn(PingPongActor).unwrap();
|
||||
let b = rt.spawn(PingPongActor).unwrap();
|
||||
rt.join_group(a, "pongers");
|
||||
rt.join_group(b, "pongers");
|
||||
|
||||
rt.tick(); // on_start
|
||||
|
||||
// Publish a Ping with inbox1's addr as reply_to.
|
||||
let count = rt.publish_to("pongers", Ping { reply_to: *inbox1.addr() });
|
||||
assert_eq!(count, 2, "two members, two messages sent");
|
||||
|
||||
rt.tick(); // actors handle Ping -> send Pong to inbox1
|
||||
|
||||
// Both actors send to inbox1
|
||||
assert!(inbox1.try_recv().is_some(), "first Pong");
|
||||
assert!(inbox1.try_recv().is_some(), "second Pong");
|
||||
assert!(inbox1.try_recv().is_none(), "no more");
|
||||
drop(inbox2);
|
||||
}
|
||||
|
||||
/// Given an actor leaves a group,
|
||||
/// when a message is published,
|
||||
/// then the leaver does not receive it.
|
||||
#[test]
|
||||
fn leave_group_stops_receiving_publishes() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
|
||||
let a = rt.spawn(PingPongActor).unwrap();
|
||||
let b = rt.spawn(PingPongActor).unwrap();
|
||||
rt.join_group(a, "pool");
|
||||
rt.join_group(b, "pool");
|
||||
rt.leave_group(b, "pool");
|
||||
|
||||
rt.tick(); // on_start
|
||||
let count = rt.publish_to("pool", Ping { reply_to: *inbox.addr() });
|
||||
assert_eq!(count, 1, "only one member after leave");
|
||||
|
||||
rt.tick();
|
||||
assert!(inbox.try_recv().is_some(), "one Pong from remaining member");
|
||||
assert!(inbox.try_recv().is_none(), "no second Pong");
|
||||
}
|
||||
|
||||
/// Given a group member dies,
|
||||
/// when a message is published,
|
||||
/// then the dead member is not included.
|
||||
#[test]
|
||||
fn dead_actor_auto_removed_from_group() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
|
||||
let a = rt.spawn(PingPongActor).unwrap();
|
||||
let b = rt.spawn(PingPongActor).unwrap();
|
||||
rt.join_group(a, "team");
|
||||
rt.join_group(b, "team");
|
||||
|
||||
rt.tick(); // on_start
|
||||
rt.stop_actor(b).unwrap();
|
||||
rt.tick(); // b dies, cleaned up from group
|
||||
|
||||
let count = rt.publish_to("team", Ping { reply_to: *inbox.addr() });
|
||||
assert_eq!(count, 1, "dead actor removed from group");
|
||||
|
||||
rt.tick();
|
||||
assert!(inbox.try_recv().is_some());
|
||||
assert!(inbox.try_recv().is_none());
|
||||
}
|
||||
|
||||
/// Given an actor is in multiple groups,
|
||||
/// when the actor dies,
|
||||
/// then it is removed from all groups.
|
||||
#[test]
|
||||
fn actor_removed_from_all_groups_on_death() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let actor = rt.spawn(PingPongActor).unwrap();
|
||||
rt.join_group(actor, "alpha");
|
||||
rt.join_group(actor, "beta");
|
||||
rt.join_group(actor, "gamma");
|
||||
|
||||
rt.tick();
|
||||
rt.stop_actor(actor).unwrap();
|
||||
rt.tick(); // cleanup removes from all groups
|
||||
|
||||
assert!(rt.group_members("alpha").is_empty());
|
||||
assert!(rt.group_members("beta").is_empty());
|
||||
assert!(rt.group_members("gamma").is_empty());
|
||||
}
|
||||
|
||||
/// Given a group becomes empty after its last member leaves,
|
||||
/// then the group name disappears from the active groups list.
|
||||
#[test]
|
||||
fn empty_group_auto_deleted() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let actor = rt.spawn(PingPongActor).unwrap();
|
||||
rt.join_group(actor, "temp");
|
||||
assert!(rt.groups().contains(&"temp".to_string()));
|
||||
|
||||
rt.leave_group(actor, "temp");
|
||||
assert!(!rt.groups().contains(&"temp".to_string()), "empty group should be removed");
|
||||
}
|
||||
|
||||
/// Given actors join groups from handlers using ctx.join_group(),
|
||||
/// when group_members is queried,
|
||||
/// then the joining actors are listed.
|
||||
#[test]
|
||||
fn ctx_join_group_from_handler() {
|
||||
struct GroupJoinerActor;
|
||||
|
||||
impl ActorInterface for GroupJoinerActor {
|
||||
type Incoming = Ping;
|
||||
type Response = ();
|
||||
fn on_start(&mut self, ctx: &Ctx) {
|
||||
ctx.join_group("auto-joined");
|
||||
}
|
||||
fn handle(&mut self, _ctx: &Ctx, _msg: Ping) {}
|
||||
}
|
||||
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let a = rt.spawn(GroupJoinerActor).unwrap();
|
||||
let b = rt.spawn(GroupJoinerActor).unwrap();
|
||||
|
||||
rt.tick(); // on_start -> both join "auto-joined"
|
||||
|
||||
let members = rt.group_members("auto-joined");
|
||||
assert_eq!(members.len(), 2);
|
||||
assert!(members.contains(&a));
|
||||
assert!(members.contains(&b));
|
||||
}
|
||||
|
||||
/// Given an actor uses ctx.publish() from inside a handler,
|
||||
/// when the published message is processed,
|
||||
/// then all group members receive it.
|
||||
#[test]
|
||||
fn ctx_publish_broadcasts_from_handler() {
|
||||
#[derive(Clone)]
|
||||
struct BroadcastCmd {
|
||||
reply_to: ActorAddress,
|
||||
}
|
||||
|
||||
struct BroadcasterActor;
|
||||
|
||||
impl ActorInterface for BroadcasterActor {
|
||||
type Incoming = BroadcastCmd;
|
||||
type Response = ();
|
||||
fn on_start(&mut self, ctx: &Ctx) {
|
||||
ctx.join_group("broadcast-test");
|
||||
}
|
||||
fn handle(&mut self, ctx: &Ctx, msg: BroadcastCmd) {
|
||||
ctx.publish("broadcast-test", Ping { reply_to: msg.reply_to });
|
||||
}
|
||||
}
|
||||
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
|
||||
// Spawn 3 PingPongActors and one Broadcaster, all in the same group
|
||||
let _p1 = rt.spawn(PingPongActor).unwrap();
|
||||
let _p2 = rt.spawn(PingPongActor).unwrap();
|
||||
rt.join_group(_p1, "broadcast-test");
|
||||
rt.join_group(_p2, "broadcast-test");
|
||||
|
||||
let broadcaster = rt.spawn(BroadcasterActor).unwrap();
|
||||
|
||||
rt.tick(); // on_start (broadcaster joins group too)
|
||||
|
||||
// Send BroadcastCmd to broadcaster
|
||||
rt.send_to(broadcaster, BroadcastCmd { reply_to: *inbox.addr() }).unwrap();
|
||||
rt.tick(); // broadcaster handles -> publish Ping to all 3 members (including self)
|
||||
rt.tick(); // PingPong actors handle Ping -> send Pong to inbox
|
||||
|
||||
// At least 2 Pongs from the PingPongActors
|
||||
let mut pong_count = 0;
|
||||
while inbox.try_recv().is_some() {
|
||||
pong_count += 1;
|
||||
}
|
||||
assert!(pong_count >= 2, "at least 2 PingPong members should reply, got {pong_count}");
|
||||
}
|
||||
|
||||
// ── Ask Pattern ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Given a PingPong actor,
|
||||
/// when I ask with recv_ticking,
|
||||
/// then I get the Pong response.
|
||||
#[test]
|
||||
fn ask_recv_ticking_returns_response() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let actor = rt.spawn(PingPongActor).unwrap();
|
||||
rt.tick(); // on_start
|
||||
|
||||
let pong: Pong = rt.ask(actor, |reply_to| Ping { reply_to })
|
||||
.unwrap()
|
||||
.recv_ticking(&rt, 10)
|
||||
.unwrap();
|
||||
assert_eq!(pong, Pong);
|
||||
}
|
||||
|
||||
/// Given a CounterActor,
|
||||
/// when I ask multiple times,
|
||||
/// then each response reflects the updated state.
|
||||
#[test]
|
||||
fn ask_multiple_times_tracks_state() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let actor = rt.spawn(CounterActor { count: 0 }).unwrap();
|
||||
rt.tick(); // on_start
|
||||
|
||||
let c1: Count = rt.ask(actor, |reply_to| Increment { reply_to })
|
||||
.unwrap().recv_ticking(&rt, 10).unwrap();
|
||||
let c2: Count = rt.ask(actor, |reply_to| Increment { reply_to })
|
||||
.unwrap().recv_ticking(&rt, 10).unwrap();
|
||||
let c3: Count = rt.ask(actor, |reply_to| Increment { reply_to })
|
||||
.unwrap().recv_ticking(&rt, 10).unwrap();
|
||||
|
||||
assert_eq!(c1, Count(1));
|
||||
assert_eq!(c2, Count(2));
|
||||
assert_eq!(c3, Count(3));
|
||||
}
|
||||
|
||||
/// Given a dead actor,
|
||||
/// when I ask and tick,
|
||||
/// then recv_ticking returns a timeout error.
|
||||
#[test]
|
||||
fn ask_timeout_when_no_response() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let actor = rt.spawn(PingPongActor).unwrap();
|
||||
rt.tick();
|
||||
rt.stop_actor(actor).unwrap();
|
||||
rt.tick(); // actor dies
|
||||
|
||||
// Ask the dead actor -- message is undeliverable, no response
|
||||
let result = rt.ask::<Ping, Pong>(actor, |reply_to| Ping { reply_to });
|
||||
// send_to may succeed or fail
|
||||
if let Ok(ask) = result {
|
||||
let err = ask.recv_ticking(&rt, 5);
|
||||
assert!(err.is_err(), "should timeout with no response");
|
||||
}
|
||||
}
|
||||
|
||||
/// Given an ask handle,
|
||||
/// when I use try_recv before ticking,
|
||||
/// then it returns None (response hasn't arrived yet).
|
||||
#[test]
|
||||
fn ask_try_recv_returns_none_before_tick() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let actor = rt.spawn(PingPongActor).unwrap();
|
||||
rt.tick(); // on_start
|
||||
|
||||
let ask = rt.ask::<Ping, Pong>(actor, |reply_to| Ping { reply_to }).unwrap();
|
||||
assert!(ask.try_recv().is_none(), "no response before ticking");
|
||||
|
||||
rt.tick(); // process message
|
||||
assert_eq!(ask.try_recv(), Some(Pong));
|
||||
}
|
||||
|
||||
/// Given an ask, the reply_addr() returns the inbox address for manual use.
|
||||
#[test]
|
||||
fn ask_reply_addr_is_accessible() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let actor = rt.spawn(PingPongActor).unwrap();
|
||||
rt.tick();
|
||||
|
||||
let ask = rt.ask::<Ping, Pong>(actor, |reply_to| Ping { reply_to }).unwrap();
|
||||
let addr = *ask.reply_addr();
|
||||
// The address should be valid (non-zero)
|
||||
assert_ne!(addr, ActorAddress::default());
|
||||
}
|
||||
866
tests/runtime_supervision.rs
Normal file
866
tests/runtime_supervision.rs
Normal file
|
|
@ -0,0 +1,866 @@
|
|||
mod common;
|
||||
use common::*;
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
// ─── Supervisor Helpers ────────────────────────────────────────────────────
|
||||
|
||||
/// Actor that panics after receiving a configurable number of messages.
|
||||
struct PanicAfterN {
|
||||
trigger: usize,
|
||||
count: usize,
|
||||
counter: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl ActorInterface for PanicAfterN {
|
||||
type Incoming = Ping;
|
||||
type Response = ();
|
||||
fn handle(&mut self, ctx: &Ctx, msg: Ping) {
|
||||
self.count += 1;
|
||||
self.counter.fetch_add(1, Ordering::SeqCst);
|
||||
let _ = ctx.send(msg.reply_to, Pong);
|
||||
if self.count >= self.trigger {
|
||||
panic!("intentional panic at message {}", self.count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- handle_down tests ---
|
||||
|
||||
/// Given an actor with handle_down and a monitored target,
|
||||
/// when the target dies, the watcher receives a Down via handle_down.
|
||||
#[test]
|
||||
fn handle_down_receives_death_notification() {
|
||||
struct MonitoringTracker {
|
||||
target: ActorAddress,
|
||||
downs: Vec<Down>,
|
||||
inbox: ActorAddress,
|
||||
}
|
||||
impl ActorInterface for MonitoringTracker {
|
||||
type Incoming = Ping;
|
||||
type Response = ();
|
||||
fn on_start(&mut self, ctx: &Ctx) {
|
||||
ctx.monitor(self.target);
|
||||
}
|
||||
fn handle(&mut self, ctx: &Ctx, _msg: Ping) {
|
||||
let _ = ctx.send(self.inbox, Count(self.downs.len()));
|
||||
}
|
||||
fn handle_down(&mut self, _ctx: &Ctx, down: Down) {
|
||||
self.downs.push(down);
|
||||
}
|
||||
}
|
||||
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Count>().unwrap();
|
||||
let inbox_addr = *inbox.addr();
|
||||
let target = rt.spawn(PanicActor).unwrap();
|
||||
let tracker = rt.spawn(MonitoringTracker {
|
||||
target,
|
||||
downs: vec![],
|
||||
inbox: inbox_addr,
|
||||
}).unwrap();
|
||||
rt.tick(); // on_start for both
|
||||
|
||||
// Kill the target
|
||||
rt.send_to(target, PanicMsg).unwrap();
|
||||
rt.tick(); // target panics
|
||||
rt.tick(); // Down delivered to tracker via handle_down
|
||||
|
||||
// Ask tracker how many downs it saw
|
||||
rt.send_to(tracker, Ping { reply_to: inbox_addr }).unwrap();
|
||||
rt.tick();
|
||||
assert_eq!(inbox.try_recv(), Some(Count(1)));
|
||||
}
|
||||
|
||||
/// Given an actor whose Incoming type IS Down, handle_down is NOT called --
|
||||
/// the Down goes through the normal handle() method (backward compatibility).
|
||||
#[test]
|
||||
fn handle_down_skipped_when_incoming_is_down() {
|
||||
struct DownAsIncoming {
|
||||
target: ActorAddress,
|
||||
inbox: ActorAddress,
|
||||
}
|
||||
impl ActorInterface for DownAsIncoming {
|
||||
type Incoming = Down;
|
||||
type Response = ();
|
||||
fn on_start(&mut self, ctx: &Ctx) {
|
||||
ctx.monitor(self.target);
|
||||
}
|
||||
fn handle(&mut self, ctx: &Ctx, msg: Down) {
|
||||
let _ = ctx.send(self.inbox, msg);
|
||||
}
|
||||
fn handle_down(&mut self, _ctx: &Ctx, _down: Down) {
|
||||
panic!("handle_down must not be called when Incoming=Down");
|
||||
}
|
||||
}
|
||||
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Down>().unwrap();
|
||||
let inbox_addr = *inbox.addr();
|
||||
let target = rt.spawn(PanicActor).unwrap();
|
||||
let _watcher = rt.spawn(DownAsIncoming { target, inbox: inbox_addr }).unwrap();
|
||||
rt.tick(); // on_start
|
||||
|
||||
rt.send_to(target, PanicMsg).unwrap();
|
||||
rt.tick(); // panic
|
||||
rt.tick(); // Down delivered through handle(), not handle_down
|
||||
|
||||
let received = inbox.try_recv().expect("Down should be delivered via handle()");
|
||||
assert_eq!(received.reason, StopReason::Panicked);
|
||||
}
|
||||
|
||||
// --- ctx.stop_actor tests ---
|
||||
|
||||
/// Given two actors, one can stop the other via ctx.stop_actor().
|
||||
#[test]
|
||||
fn ctx_stop_actor_stops_target() {
|
||||
#[derive(Clone)]
|
||||
struct StopCmd {
|
||||
target: ActorAddress,
|
||||
}
|
||||
struct Stopper;
|
||||
impl ActorInterface for Stopper {
|
||||
type Incoming = StopCmd;
|
||||
type Response = ();
|
||||
fn handle(&mut self, ctx: &Ctx, msg: StopCmd) {
|
||||
let _ = ctx.stop_actor(msg.target);
|
||||
}
|
||||
}
|
||||
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let target = rt.spawn(PingPongActor).unwrap();
|
||||
let stopper = rt.spawn(Stopper).unwrap();
|
||||
rt.tick(); // on_start
|
||||
|
||||
rt.send_to(stopper, StopCmd { target }).unwrap();
|
||||
rt.tick(); // stopper handles StopCmd -> stop_actor(target)
|
||||
rt.tick(); // StopSignal delivered to target, target stops
|
||||
rt.tick(); // cleanup
|
||||
|
||||
assert!(rt.send_to(target, Ping { reply_to: ActorAddress::default() }).is_err());
|
||||
// Stopper should still be alive
|
||||
assert!(rt.send_to(stopper, StopCmd { target }).is_ok());
|
||||
}
|
||||
|
||||
// --- Supervisor tests ---
|
||||
|
||||
/// Given a supervisor with one permanent child,
|
||||
/// when the child panics, the supervisor restarts it.
|
||||
#[test]
|
||||
fn supervisor_restarts_permanent_child_on_panic() {
|
||||
let counter = Arc::new(AtomicUsize::new(0));
|
||||
let counter_c = counter.clone();
|
||||
let inbox_holder: Arc<std::sync::Mutex<Option<ActorAddress>>> =
|
||||
Arc::new(std::sync::Mutex::new(None));
|
||||
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
let inbox_addr = *inbox.addr();
|
||||
*inbox_holder.lock().unwrap() = Some(inbox_addr);
|
||||
|
||||
let sup = Supervisor::new(
|
||||
SupervisorStrategy::OneForOne,
|
||||
5,
|
||||
vec![ChildSpec::new("worker", RestartPolicy::Permanent, move |ctx| {
|
||||
ctx.spawn(PanicAfterN {
|
||||
trigger: 2, // panics on 2nd message
|
||||
count: 0,
|
||||
counter: counter_c.clone(),
|
||||
})
|
||||
})],
|
||||
);
|
||||
let _sup_addr = rt.spawn(sup).unwrap();
|
||||
rt.tick(); // supervisor on_start -> spawns child
|
||||
rt.tick(); // child on_start
|
||||
|
||||
// Find the child by checking stats
|
||||
let stats = rt.stats();
|
||||
assert_eq!(stats.workers[0].num_actors, 2); // supervisor + child
|
||||
|
||||
// Discover child address from stats
|
||||
let child_addr = stats.actors.iter()
|
||||
.find(|(addr, _)| *addr != _sup_addr)
|
||||
.map(|(addr, _)| *addr)
|
||||
.unwrap();
|
||||
|
||||
// First message: child processes, increments counter
|
||||
rt.send_to(child_addr, Ping { reply_to: inbox_addr }).unwrap();
|
||||
rt.tick();
|
||||
assert_eq!(counter.load(Ordering::SeqCst), 1);
|
||||
|
||||
// Second message: child panics (trigger=2)
|
||||
rt.send_to(child_addr, Ping { reply_to: inbox_addr }).unwrap();
|
||||
rt.tick(); // child panics and is poisoned
|
||||
rt.tick(); // cleanup: Down delivered to supervisor via handle_down
|
||||
rt.tick(); // supervisor restarts child (spawns new one)
|
||||
rt.tick(); // new child on_start
|
||||
|
||||
// Supervisor is still alive, and a new child exists
|
||||
let stats = rt.stats();
|
||||
assert_eq!(stats.workers[0].num_actors, 2); // supervisor + new child
|
||||
}
|
||||
|
||||
/// Given a supervisor with a transient child,
|
||||
/// when the child stops normally, it is NOT restarted.
|
||||
#[test]
|
||||
fn supervisor_does_not_restart_transient_child_on_normal_stop() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
|
||||
struct StopsAfterFirst;
|
||||
impl ActorInterface for StopsAfterFirst {
|
||||
type Incoming = Ping;
|
||||
type Response = ();
|
||||
fn handle(&mut self, ctx: &Ctx, _msg: Ping) {
|
||||
ctx.stop_self();
|
||||
}
|
||||
}
|
||||
|
||||
let sup = Supervisor::new(
|
||||
SupervisorStrategy::OneForOne,
|
||||
5,
|
||||
vec![ChildSpec::new("worker", RestartPolicy::Transient, |ctx| {
|
||||
ctx.spawn(StopsAfterFirst)
|
||||
})],
|
||||
);
|
||||
let sup_addr = rt.spawn(sup).unwrap();
|
||||
rt.tick(); // supervisor on_start -> child spawned
|
||||
rt.tick(); // child on_start
|
||||
|
||||
let stats = rt.stats();
|
||||
assert_eq!(stats.workers[0].num_actors, 2); // sup + child
|
||||
|
||||
// Find child address
|
||||
let child_addr = stats.actors.iter()
|
||||
.find(|(addr, _)| *addr != sup_addr)
|
||||
.map(|(addr, _)| *addr)
|
||||
.unwrap();
|
||||
|
||||
// Send message -- child stops itself
|
||||
rt.send_to(child_addr, Ping { reply_to: ActorAddress::default() }).unwrap();
|
||||
rt.tick(); // child handles, stops self
|
||||
rt.tick(); // cleanup: Down(Normal) delivered to supervisor
|
||||
rt.tick(); // supervisor sees Transient + Normal -> no restart
|
||||
|
||||
let stats = rt.stats();
|
||||
assert_eq!(stats.workers[0].num_actors, 1); // only supervisor remains
|
||||
}
|
||||
|
||||
/// Given a supervisor with a transient child,
|
||||
/// when the child panics, it IS restarted.
|
||||
#[test]
|
||||
fn supervisor_restarts_transient_child_on_panic() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let counter = Arc::new(AtomicUsize::new(0));
|
||||
let counter_c = counter.clone();
|
||||
|
||||
let sup = Supervisor::new(
|
||||
SupervisorStrategy::OneForOne,
|
||||
5,
|
||||
vec![ChildSpec::new("worker", RestartPolicy::Transient, move |ctx| {
|
||||
ctx.spawn(PanicAfterN {
|
||||
trigger: 1, // panics on first message
|
||||
count: 0,
|
||||
counter: counter_c.clone(),
|
||||
})
|
||||
})],
|
||||
);
|
||||
let sup_addr = rt.spawn(sup).unwrap();
|
||||
rt.tick(); // supervisor starts, spawns child
|
||||
rt.tick(); // child on_start
|
||||
|
||||
let child_addr = rt.stats().actors.iter()
|
||||
.find(|(addr, _)| *addr != sup_addr)
|
||||
.map(|(addr, _)| *addr)
|
||||
.unwrap();
|
||||
|
||||
// Send message -- child panics
|
||||
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
rt.send_to(child_addr, Ping { reply_to: *inbox.addr() }).unwrap();
|
||||
rt.tick(); // child panics
|
||||
rt.tick(); // Down(Panicked) -> supervisor restarts
|
||||
rt.tick(); // new child spawned
|
||||
rt.tick(); // new child on_start
|
||||
|
||||
// Supervisor + new child alive
|
||||
let stats = rt.stats();
|
||||
assert_eq!(stats.workers[0].num_actors, 2);
|
||||
}
|
||||
|
||||
/// Given a supervisor with a temporary child,
|
||||
/// when the child dies (any reason), it is never restarted.
|
||||
#[test]
|
||||
fn supervisor_never_restarts_temporary_child() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
|
||||
let sup = Supervisor::new(
|
||||
SupervisorStrategy::OneForOne,
|
||||
5,
|
||||
vec![ChildSpec::new("worker", RestartPolicy::Temporary, |ctx| {
|
||||
ctx.spawn(PanicActor)
|
||||
})],
|
||||
);
|
||||
let sup_addr = rt.spawn(sup).unwrap();
|
||||
rt.tick(); // supervisor starts, spawns child
|
||||
rt.tick(); // child on_start
|
||||
|
||||
let child_addr = rt.stats().actors.iter()
|
||||
.find(|(addr, _)| *addr != sup_addr)
|
||||
.map(|(addr, _)| *addr)
|
||||
.unwrap();
|
||||
|
||||
// Kill the child
|
||||
rt.send_to(child_addr, PanicMsg).unwrap();
|
||||
rt.tick(); // panic
|
||||
rt.tick(); // Down -> supervisor sees Temporary -> no restart
|
||||
rt.tick(); // settle
|
||||
|
||||
let stats = rt.stats();
|
||||
assert_eq!(stats.workers[0].num_actors, 1); // only supervisor
|
||||
}
|
||||
|
||||
/// Given a supervisor with max_restarts=2,
|
||||
/// when more than 2 restarts occur, the supervisor stops itself (meltdown).
|
||||
#[test]
|
||||
fn supervisor_meltdown_after_max_restarts() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let counter = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let sup = Supervisor::new(
|
||||
SupervisorStrategy::OneForOne,
|
||||
2, // only 2 restarts allowed
|
||||
vec![ChildSpec::new("crasher", RestartPolicy::Permanent, {
|
||||
let counter = counter.clone();
|
||||
move |ctx| {
|
||||
ctx.spawn(PanicAfterN {
|
||||
trigger: 1,
|
||||
count: 0,
|
||||
counter: counter.clone(),
|
||||
})
|
||||
}
|
||||
})],
|
||||
);
|
||||
let sup_addr = rt.spawn(sup).unwrap();
|
||||
rt.tick(); rt.tick(); // supervisor + child started
|
||||
|
||||
// Crash the child 3 times
|
||||
for _ in 0..3 {
|
||||
if let Some((child_addr, _)) = rt.stats().actors.iter()
|
||||
.find(|(addr, _)| *addr != sup_addr)
|
||||
{
|
||||
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
let _ = rt.send_to(*child_addr, Ping { reply_to: *inbox.addr() });
|
||||
rt.tick(); // child panics
|
||||
rt.tick(); // Down delivered -> restart or meltdown
|
||||
rt.tick(); // new child spawned (or supervisor stopped)
|
||||
rt.tick(); // settle
|
||||
}
|
||||
}
|
||||
|
||||
// After 3 crashes with max_restarts=2, supervisor should have stopped itself
|
||||
let stats = rt.stats();
|
||||
let sup_alive = stats.actors.iter().any(|(addr, _)| *addr == sup_addr);
|
||||
assert!(!sup_alive, "supervisor should have stopped after exceeding max_restarts");
|
||||
}
|
||||
|
||||
/// Given a supervisor with multiple children,
|
||||
/// when one child panics, only that child is restarted (OneForOne).
|
||||
#[test]
|
||||
fn supervisor_one_for_one_only_restarts_failed_child() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let counter_a = Arc::new(AtomicUsize::new(0));
|
||||
let counter_b = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let sup = Supervisor::new(
|
||||
SupervisorStrategy::OneForOne,
|
||||
5,
|
||||
vec![
|
||||
ChildSpec::new("crasher", RestartPolicy::Permanent, {
|
||||
let c = counter_a.clone();
|
||||
move |ctx| ctx.spawn_named("child_a", PanicAfterN {
|
||||
trigger: 1, count: 0, counter: c.clone(),
|
||||
})
|
||||
}),
|
||||
ChildSpec::new("stable", RestartPolicy::Permanent, {
|
||||
let c = counter_b.clone();
|
||||
move |ctx| ctx.spawn_named("child_b", CountingPingActor { counter: c.clone() })
|
||||
}),
|
||||
],
|
||||
);
|
||||
let _sup_addr = rt.spawn(sup).unwrap();
|
||||
rt.tick(); rt.tick(); // start up
|
||||
|
||||
let child_a = rt.where_is("child_a").expect("child_a should be named");
|
||||
let child_b = rt.where_is("child_b").expect("child_b should be named");
|
||||
|
||||
// Send to child_b to prove it's alive
|
||||
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
rt.send_to(child_b, Ping { reply_to: *inbox.addr() }).unwrap();
|
||||
rt.tick();
|
||||
let b_processed_before = counter_b.load(Ordering::SeqCst);
|
||||
assert!(b_processed_before >= 1);
|
||||
|
||||
// Crash child_a
|
||||
rt.send_to(child_a, Ping { reply_to: *inbox.addr() }).unwrap();
|
||||
rt.tick(); // child_a panics
|
||||
rt.tick(); // Down -> supervisor restarts child_a
|
||||
rt.tick(); rt.tick(); // new child spawned + on_start
|
||||
|
||||
// child_b should still be alive (same address, same name)
|
||||
let child_b_after = rt.where_is("child_b").expect("child_b should still exist");
|
||||
assert_eq!(child_b, child_b_after, "child_b address should be unchanged");
|
||||
|
||||
rt.send_to(child_b, Ping { reply_to: *inbox.addr() }).unwrap();
|
||||
rt.tick();
|
||||
assert!(counter_b.load(Ordering::SeqCst) > b_processed_before,
|
||||
"child_b should still be processing messages");
|
||||
|
||||
// Supervisor + 2 children should be alive
|
||||
assert_eq!(rt.stats().workers[0].num_actors, 3);
|
||||
}
|
||||
|
||||
/// Given a OneForAll supervisor with 3 children,
|
||||
/// when one child panics, ALL children are stopped and restarted in spec order.
|
||||
#[test]
|
||||
fn supervisor_one_for_all_restarts_all_on_single_failure() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let counter_a = Arc::new(AtomicUsize::new(0));
|
||||
let counter_b = Arc::new(AtomicUsize::new(0));
|
||||
let counter_c = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let sup = Supervisor::new(
|
||||
SupervisorStrategy::OneForAll,
|
||||
5,
|
||||
vec![
|
||||
ChildSpec::new("a", RestartPolicy::Permanent, {
|
||||
let c = counter_a.clone();
|
||||
move |ctx| ctx.spawn_named("ofa_a", PanicAfterN {
|
||||
trigger: 1, count: 0, counter: c.clone(),
|
||||
})
|
||||
}),
|
||||
ChildSpec::new("b", RestartPolicy::Permanent, {
|
||||
let c = counter_b.clone();
|
||||
move |ctx| ctx.spawn_named("ofa_b", CountingPingActor { counter: c.clone() })
|
||||
}),
|
||||
ChildSpec::new("c", RestartPolicy::Permanent, {
|
||||
let c = counter_c.clone();
|
||||
move |ctx| ctx.spawn_named("ofa_c", CountingPingActor { counter: c.clone() })
|
||||
}),
|
||||
],
|
||||
);
|
||||
let _sup_addr = rt.spawn(sup).unwrap();
|
||||
rt.tick(); rt.tick(); // startup
|
||||
|
||||
let old_b = rt.where_is("ofa_b").expect("ofa_b exists");
|
||||
let old_c = rt.where_is("ofa_c").expect("ofa_c exists");
|
||||
let child_a = rt.where_is("ofa_a").expect("ofa_a exists");
|
||||
|
||||
// Crash child_a
|
||||
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
rt.send_to(child_a, Ping { reply_to: *inbox.addr() }).unwrap();
|
||||
rt.tick(); // child_a panics
|
||||
// supervisor receives Down(a) -> OneForAll -> stops b and c
|
||||
for _ in 0..8 { rt.tick(); }
|
||||
|
||||
// All 3 children should be alive with NEW addresses
|
||||
let stats = rt.stats();
|
||||
assert_eq!(stats.workers[0].num_actors, 4); // sup + 3 new children
|
||||
|
||||
let new_b = rt.where_is("ofa_b").expect("ofa_b re-registered after restart");
|
||||
let new_c = rt.where_is("ofa_c").expect("ofa_c re-registered after restart");
|
||||
assert_ne!(old_b, new_b, "child_b should have a new address after restart");
|
||||
assert_ne!(old_c, new_c, "child_c should have a new address after restart");
|
||||
}
|
||||
|
||||
/// Given a RestForOne supervisor with children [a, b, c],
|
||||
/// when child b panics, children b and c are restarted.
|
||||
/// Child a is unaffected.
|
||||
#[test]
|
||||
fn supervisor_rest_for_one_restarts_rest_after_failed() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let counter_a = Arc::new(AtomicUsize::new(0));
|
||||
let counter_b = Arc::new(AtomicUsize::new(0));
|
||||
let counter_c = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let sup = Supervisor::new(
|
||||
SupervisorStrategy::RestForOne,
|
||||
5,
|
||||
vec![
|
||||
ChildSpec::new("a", RestartPolicy::Permanent, {
|
||||
let c = counter_a.clone();
|
||||
move |ctx| ctx.spawn_named("rfo_a", CountingPingActor { counter: c.clone() })
|
||||
}),
|
||||
ChildSpec::new("b", RestartPolicy::Permanent, {
|
||||
let c = counter_b.clone();
|
||||
move |ctx| ctx.spawn_named("rfo_b", PanicAfterN {
|
||||
trigger: 1, count: 0, counter: c.clone(),
|
||||
})
|
||||
}),
|
||||
ChildSpec::new("c", RestartPolicy::Permanent, {
|
||||
let c = counter_c.clone();
|
||||
move |ctx| ctx.spawn_named("rfo_c", CountingPingActor { counter: c.clone() })
|
||||
}),
|
||||
],
|
||||
);
|
||||
let _sup_addr = rt.spawn(sup).unwrap();
|
||||
rt.tick(); rt.tick(); // startup
|
||||
|
||||
let old_a = rt.where_is("rfo_a").expect("rfo_a exists");
|
||||
let old_c = rt.where_is("rfo_c").expect("rfo_c exists");
|
||||
let child_b = rt.where_is("rfo_b").expect("rfo_b exists");
|
||||
|
||||
// Crash child_b
|
||||
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||
rt.send_to(child_b, Ping { reply_to: *inbox.addr() }).unwrap();
|
||||
rt.tick(); // child_b panics
|
||||
for _ in 0..8 { rt.tick(); }
|
||||
|
||||
// All 3 children should be alive
|
||||
let stats = rt.stats();
|
||||
assert_eq!(stats.workers[0].num_actors, 4); // sup + 3 children
|
||||
|
||||
// child_a should be UNCHANGED
|
||||
let new_a = rt.where_is("rfo_a").expect("rfo_a still exists");
|
||||
assert_eq!(old_a, new_a, "child_a should not be restarted in RestForOne when b fails");
|
||||
|
||||
// child_c should have a NEW address
|
||||
let new_c = rt.where_is("rfo_c").expect("rfo_c re-registered");
|
||||
assert_ne!(old_c, new_c, "child_c should have a new address after RestForOne restart");
|
||||
}
|
||||
|
||||
/// Given a OneForAll supervisor, when the last child of the failed set confirms death,
|
||||
/// all children are restarted in spec order.
|
||||
#[test]
|
||||
fn supervisor_one_for_all_waits_for_all_downs_before_restart() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
|
||||
let sup = Supervisor::new(
|
||||
SupervisorStrategy::OneForAll,
|
||||
5,
|
||||
vec![
|
||||
ChildSpec::new("x", RestartPolicy::Permanent, |ctx| ctx.spawn(PingPongActor)),
|
||||
ChildSpec::new("y", RestartPolicy::Permanent, |ctx| ctx.spawn(PingPongActor)),
|
||||
],
|
||||
);
|
||||
let sup_addr = rt.spawn(sup).unwrap();
|
||||
rt.tick(); rt.tick(); // startup
|
||||
|
||||
assert_eq!(rt.stats().workers[0].num_actors, 3); // sup + 2 children
|
||||
|
||||
// Stop one child
|
||||
let actors: Vec<_> = rt.stats().actors.iter()
|
||||
.filter(|(addr, _)| *addr != sup_addr)
|
||||
.map(|(addr, _)| *addr)
|
||||
.collect();
|
||||
rt.stop_actor(actors[0]).unwrap();
|
||||
|
||||
// Tick enough times for full cycle
|
||||
for _ in 0..10 { rt.tick(); }
|
||||
|
||||
// Should have supervisor + 2 new children
|
||||
assert_eq!(rt.stats().workers[0].num_actors, 3);
|
||||
}
|
||||
|
||||
/// Given a supervisor that stops, its children also stop.
|
||||
#[test]
|
||||
fn supervisor_on_stop_kills_children() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
|
||||
let sup = Supervisor::new(
|
||||
SupervisorStrategy::OneForOne,
|
||||
5,
|
||||
vec![
|
||||
ChildSpec::new("a", RestartPolicy::Permanent, |ctx| ctx.spawn(PingPongActor)),
|
||||
ChildSpec::new("b", RestartPolicy::Permanent, |ctx| ctx.spawn(PingPongActor)),
|
||||
],
|
||||
);
|
||||
let sup_addr = rt.spawn(sup).unwrap();
|
||||
rt.tick(); rt.tick(); // start up
|
||||
|
||||
assert_eq!(rt.stats().workers[0].num_actors, 3); // sup + 2 children
|
||||
|
||||
// Stop the supervisor
|
||||
rt.stop_actor(sup_addr).unwrap();
|
||||
rt.tick(); // StopSignal delivered to supervisor, on_stop sends stop to children
|
||||
rt.tick(); // supervisor cleaned up, stop signals delivered to children
|
||||
rt.tick(); // children stop
|
||||
rt.tick(); // children cleaned up
|
||||
|
||||
assert_eq!(rt.stats().workers[0].num_actors, 0);
|
||||
}
|
||||
|
||||
// ── Router tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn router_round_robin_distributes_across_workers() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let collected = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
|
||||
struct Collector(Arc<std::sync::Mutex<Vec<(ActorAddress, usize)>>>);
|
||||
#[derive(Clone)]
|
||||
struct Work(usize);
|
||||
impl ActorInterface for Collector {
|
||||
type Incoming = Work;
|
||||
type Response = ();
|
||||
fn handle(&mut self, ctx: &Ctx, msg: Work) {
|
||||
self.0.lock().unwrap().push((ctx.self_addr(), msg.0));
|
||||
}
|
||||
}
|
||||
|
||||
let c = collected.clone();
|
||||
let router = Router::<Work>::new(
|
||||
RoutingStrategy::RoundRobin,
|
||||
3,
|
||||
move |ctx| ctx.spawn(Collector(c.clone())),
|
||||
10,
|
||||
);
|
||||
let router_addr = rt.spawn(router).unwrap();
|
||||
rt.tick(); // on_start spawns 3 workers
|
||||
|
||||
for i in 0..6 {
|
||||
rt.send_to(router_addr, Work(i)).unwrap();
|
||||
}
|
||||
rt.tick(); // router receives 6 Work messages, forwards to workers
|
||||
rt.tick(); // workers process their messages
|
||||
|
||||
let data = collected.lock().unwrap();
|
||||
assert_eq!(data.len(), 6);
|
||||
|
||||
// Count how many unique workers received messages
|
||||
let mut per_worker = std::collections::HashMap::new();
|
||||
for (addr, _) in data.iter() {
|
||||
*per_worker.entry(*addr).or_insert(0usize) += 1;
|
||||
}
|
||||
// All 3 workers should have received exactly 2 messages each
|
||||
assert_eq!(per_worker.len(), 3);
|
||||
for count in per_worker.values() {
|
||||
assert_eq!(*count, 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn router_broadcast_sends_to_all_workers() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let count = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
struct Counter(Arc<AtomicUsize>);
|
||||
#[derive(Clone)]
|
||||
struct Ping;
|
||||
impl ActorInterface for Counter {
|
||||
type Incoming = Ping;
|
||||
type Response = ();
|
||||
fn handle(&mut self, _ctx: &Ctx, _msg: Ping) {
|
||||
self.0.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
let c = count.clone();
|
||||
let router = Router::<Ping>::new(
|
||||
RoutingStrategy::Broadcast,
|
||||
3,
|
||||
move |ctx| ctx.spawn(Counter(c.clone())),
|
||||
10,
|
||||
);
|
||||
let router_addr = rt.spawn(router).unwrap();
|
||||
rt.tick(); // on_start spawns workers
|
||||
|
||||
rt.send_to(router_addr, Ping).unwrap();
|
||||
rt.tick(); // router broadcasts
|
||||
rt.tick(); // workers process
|
||||
|
||||
assert_eq!(count.load(Ordering::Relaxed), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn router_random_delivers_to_some_worker() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let collected = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
|
||||
struct Collector(Arc<std::sync::Mutex<Vec<ActorAddress>>>);
|
||||
#[derive(Clone)]
|
||||
struct Work;
|
||||
impl ActorInterface for Collector {
|
||||
type Incoming = Work;
|
||||
type Response = ();
|
||||
fn handle(&mut self, ctx: &Ctx, _msg: Work) {
|
||||
self.0.lock().unwrap().push(ctx.self_addr());
|
||||
}
|
||||
}
|
||||
|
||||
let c = collected.clone();
|
||||
let router = Router::<Work>::new(
|
||||
RoutingStrategy::Random,
|
||||
3,
|
||||
move |ctx| ctx.spawn(Collector(c.clone())),
|
||||
10,
|
||||
);
|
||||
let router_addr = rt.spawn(router).unwrap();
|
||||
rt.tick();
|
||||
|
||||
for _ in 0..30 {
|
||||
rt.send_to(router_addr, Work).unwrap();
|
||||
}
|
||||
rt.tick();
|
||||
rt.tick();
|
||||
|
||||
let data = collected.lock().unwrap();
|
||||
assert_eq!(data.len(), 30);
|
||||
|
||||
let unique: std::collections::HashSet<_> = data.iter().collect();
|
||||
assert!(unique.len() >= 2, "expected at least 2 workers used, got {}", unique.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn router_replaces_dead_worker() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let spawn_count = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
struct PanicOnFirst {
|
||||
first: bool,
|
||||
}
|
||||
#[derive(Clone)]
|
||||
struct Work;
|
||||
impl ActorInterface for PanicOnFirst {
|
||||
type Incoming = Work;
|
||||
type Response = ();
|
||||
fn handle(&mut self, _ctx: &Ctx, _msg: Work) {
|
||||
if self.first {
|
||||
self.first = false;
|
||||
panic!("first message panic");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let sc = spawn_count.clone();
|
||||
let router = Router::<Work>::new(
|
||||
RoutingStrategy::RoundRobin,
|
||||
3,
|
||||
move |ctx| {
|
||||
let n = sc.fetch_add(1, Ordering::Relaxed);
|
||||
ctx.spawn(PanicOnFirst { first: n == 0 })
|
||||
},
|
||||
10,
|
||||
);
|
||||
let router_addr = rt.spawn(router).unwrap();
|
||||
rt.tick(); // spawn workers (3 spawned)
|
||||
assert_eq!(spawn_count.load(Ordering::Relaxed), 3);
|
||||
|
||||
// Send a message that will hit worker 0
|
||||
rt.send_to(router_addr, Work).unwrap();
|
||||
rt.tick(); // router forwards to worker 0
|
||||
rt.tick(); // worker 0 panics
|
||||
rt.tick(); // cleanup + Down delivered to router
|
||||
rt.tick(); // router spawns replacement
|
||||
rt.tick(); // replacement starts
|
||||
|
||||
// Should have spawned 4 total (3 original + 1 replacement)
|
||||
assert_eq!(spawn_count.load(Ordering::Relaxed), 4);
|
||||
|
||||
// Verify all 3 slots are live
|
||||
assert_eq!(rt.stats().workers[0].num_actors, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn router_meltdown_after_max_restarts() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
|
||||
struct AlwaysPanics;
|
||||
#[derive(Clone)]
|
||||
struct Work;
|
||||
impl ActorInterface for AlwaysPanics {
|
||||
type Incoming = Work;
|
||||
type Response = ();
|
||||
fn handle(&mut self, _ctx: &Ctx, _msg: Work) {
|
||||
panic!("always");
|
||||
}
|
||||
}
|
||||
|
||||
let router = Router::<Work>::new(
|
||||
RoutingStrategy::RoundRobin,
|
||||
1,
|
||||
|ctx| ctx.spawn(AlwaysPanics),
|
||||
2, // max 2 restarts
|
||||
);
|
||||
let router_addr = rt.spawn(router).unwrap();
|
||||
rt.tick(); // on_start
|
||||
|
||||
// Kill the worker 3 times (> max_restarts=2)
|
||||
for _ in 0..3 {
|
||||
rt.send_to(router_addr, Work).unwrap();
|
||||
for _ in 0..5 {
|
||||
rt.tick();
|
||||
}
|
||||
}
|
||||
|
||||
// After 3 restarts, router should have shut down
|
||||
for _ in 0..5 {
|
||||
rt.tick();
|
||||
}
|
||||
assert_eq!(rt.stats().workers[0].num_actors, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn router_on_stop_kills_workers() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
|
||||
struct Dummy;
|
||||
#[derive(Clone)]
|
||||
struct Work;
|
||||
impl ActorInterface for Dummy {
|
||||
type Incoming = Work;
|
||||
type Response = ();
|
||||
fn handle(&mut self, _ctx: &Ctx, _msg: Work) {}
|
||||
}
|
||||
|
||||
let router = Router::<Work>::new(
|
||||
RoutingStrategy::RoundRobin,
|
||||
3,
|
||||
|ctx| ctx.spawn(Dummy),
|
||||
10,
|
||||
);
|
||||
let router_addr = rt.spawn(router).unwrap();
|
||||
rt.tick(); // on_start
|
||||
assert_eq!(rt.stats().workers[0].num_actors, 4); // router + 3 workers
|
||||
|
||||
rt.stop_actor(router_addr).unwrap();
|
||||
for _ in 0..5 {
|
||||
rt.tick();
|
||||
}
|
||||
|
||||
assert_eq!(rt.stats().workers[0].num_actors, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn router_broadcast_multiple_messages_all_received() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let total = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
struct Sink(Arc<AtomicUsize>);
|
||||
#[derive(Clone)]
|
||||
struct Tick;
|
||||
impl ActorInterface for Sink {
|
||||
type Incoming = Tick;
|
||||
type Response = ();
|
||||
fn handle(&mut self, _ctx: &Ctx, _msg: Tick) {
|
||||
self.0.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
let t = total.clone();
|
||||
let router = Router::<Tick>::new(
|
||||
RoutingStrategy::Broadcast,
|
||||
3,
|
||||
move |ctx| ctx.spawn(Sink(t.clone())),
|
||||
10,
|
||||
);
|
||||
let router_addr = rt.spawn(router).unwrap();
|
||||
rt.tick();
|
||||
|
||||
for _ in 0..5 {
|
||||
rt.send_to(router_addr, Tick).unwrap();
|
||||
}
|
||||
rt.tick(); // router broadcasts
|
||||
rt.tick(); // workers process
|
||||
|
||||
assert_eq!(total.load(Ordering::Relaxed), 15);
|
||||
}
|
||||
213
tests/runtime_timers.rs
Normal file
213
tests/runtime_timers.rs
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
mod common;
|
||||
use common::*;
|
||||
|
||||
// ── Timer Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Actor that schedules a one-shot timer in on_start: sends a Ping to target after N ticks.
|
||||
struct TimerStartActor {
|
||||
target: ActorAddress,
|
||||
delay_ticks: u64,
|
||||
}
|
||||
|
||||
impl ActorInterface for TimerStartActor {
|
||||
type Incoming = Ping;
|
||||
type Response = Pong;
|
||||
|
||||
fn on_start(&mut self, ctx: &Ctx) {
|
||||
ctx.send_after_ticks(self.target, Ping { reply_to: ctx.self_addr() }, self.delay_ticks);
|
||||
}
|
||||
|
||||
fn handle(&mut self, _ctx: &Ctx, _msg: Ping) {}
|
||||
}
|
||||
|
||||
/// Actor that schedules a one-shot timer when it receives a Forward message.
|
||||
struct DelayPingPongActor;
|
||||
|
||||
impl ActorInterface for DelayPingPongActor {
|
||||
type Incoming = Forward;
|
||||
type Response = Done;
|
||||
|
||||
fn handle(&mut self, ctx: &Ctx, msg: Forward) {
|
||||
ctx.send_after_ticks(msg.reply_to, Done(msg.value), 3);
|
||||
}
|
||||
}
|
||||
|
||||
/// Actor that schedules an interval timer on start: sends Ping every N ticks.
|
||||
struct HeartbeatActor {
|
||||
target: ActorAddress,
|
||||
period: u64,
|
||||
}
|
||||
|
||||
impl ActorInterface for HeartbeatActor {
|
||||
type Incoming = Ping;
|
||||
type Response = Pong;
|
||||
|
||||
fn on_start(&mut self, ctx: &Ctx) {
|
||||
ctx.send_interval_ticks(self.target, Ping { reply_to: ctx.self_addr() }, self.period);
|
||||
}
|
||||
|
||||
fn handle(&mut self, _ctx: &Ctx, _msg: Ping) {}
|
||||
}
|
||||
|
||||
// ── Timer Tests ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Given an actor that schedules a one-shot timer in on_start,
|
||||
/// when enough ticks pass,
|
||||
/// then the timer message is delivered to the target.
|
||||
#[test]
|
||||
fn one_shot_timer_fires_after_n_ticks() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Ping>().unwrap();
|
||||
|
||||
let _timer_actor = rt.spawn(TimerStartActor {
|
||||
target: *inbox.addr(),
|
||||
delay_ticks: 3,
|
||||
}).unwrap();
|
||||
|
||||
// Tick 1: on_start schedules timer (fire_at = current_tick + 3 = 4)
|
||||
rt.tick(); // tick 1: on_start, timer scheduled
|
||||
assert!(inbox.try_recv().is_none(), "no delivery before delay");
|
||||
|
||||
rt.tick(); // tick 2
|
||||
assert!(inbox.try_recv().is_none(), "no delivery on tick 2");
|
||||
|
||||
rt.tick(); // tick 3
|
||||
assert!(inbox.try_recv().is_none(), "no delivery on tick 3");
|
||||
|
||||
rt.tick(); // tick 4: timer fires
|
||||
let msg = inbox.try_recv();
|
||||
assert!(msg.is_some(), "timer message delivered after 3-tick delay");
|
||||
}
|
||||
|
||||
/// Given an actor that schedules a one-shot timer from a message handler,
|
||||
/// when enough ticks pass after the triggering message,
|
||||
/// then the delayed response arrives.
|
||||
#[test]
|
||||
fn handler_can_schedule_one_shot_timer() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Done>().unwrap();
|
||||
|
||||
let addr = rt.spawn(DelayPingPongActor).unwrap();
|
||||
|
||||
let _ = rt.send_to(addr, Forward { value: 42, reply_to: *inbox.addr() });
|
||||
rt.tick(); // process Forward, schedule timer (delay=3)
|
||||
|
||||
assert!(inbox.try_recv().is_none(), "no immediate reply");
|
||||
|
||||
rt.tick(); // tick 2
|
||||
rt.tick(); // tick 3
|
||||
assert!(inbox.try_recv().is_none(), "not yet");
|
||||
|
||||
rt.tick(); // tick 4: timer fires
|
||||
let reply = inbox.try_recv();
|
||||
assert_eq!(reply, Some(Done(42)), "delayed reply arrives after 3 ticks");
|
||||
}
|
||||
|
||||
/// Given a one-shot timer,
|
||||
/// when it fires,
|
||||
/// then it does NOT fire again on subsequent ticks (consumed).
|
||||
#[test]
|
||||
fn one_shot_timer_fires_only_once() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Ping>().unwrap();
|
||||
|
||||
let _timer_actor = rt.spawn(TimerStartActor {
|
||||
target: *inbox.addr(),
|
||||
delay_ticks: 1,
|
||||
}).unwrap();
|
||||
|
||||
rt.tick(); // on_start schedules timer
|
||||
rt.tick(); // timer fires
|
||||
assert!(inbox.try_recv().is_some(), "first fire");
|
||||
|
||||
// Subsequent ticks should NOT fire again
|
||||
for _ in 0..5 { rt.tick(); }
|
||||
assert!(inbox.try_recv().is_none(), "one-shot does not repeat");
|
||||
}
|
||||
|
||||
/// Given an interval timer with period 2,
|
||||
/// when multiple ticks pass,
|
||||
/// then the timer fires repeatedly every 2 ticks.
|
||||
#[test]
|
||||
fn interval_timer_fires_repeatedly() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Ping>().unwrap();
|
||||
|
||||
let _heartbeat = rt.spawn(HeartbeatActor {
|
||||
target: *inbox.addr(),
|
||||
period: 2,
|
||||
}).unwrap();
|
||||
|
||||
rt.tick(); // tick 1: on_start, interval scheduled (next_fire = current + 2 = 3)
|
||||
assert!(inbox.try_recv().is_none(), "no fire on tick 1");
|
||||
|
||||
rt.tick(); // tick 2
|
||||
assert!(inbox.try_recv().is_none(), "no fire on tick 2");
|
||||
|
||||
rt.tick(); // tick 3: first fire
|
||||
assert!(inbox.try_recv().is_some(), "fire on tick 3");
|
||||
|
||||
rt.tick(); // tick 4
|
||||
assert!(inbox.try_recv().is_none(), "no fire on tick 4");
|
||||
|
||||
rt.tick(); // tick 5: second fire
|
||||
assert!(inbox.try_recv().is_some(), "fire on tick 5");
|
||||
|
||||
rt.tick(); // tick 6
|
||||
assert!(inbox.try_recv().is_none(), "no fire on tick 6");
|
||||
|
||||
rt.tick(); // tick 7: third fire
|
||||
assert!(inbox.try_recv().is_some(), "fire on tick 7");
|
||||
}
|
||||
|
||||
/// Given an interval timer targeting an actor that gets stopped,
|
||||
/// when the actor is removed,
|
||||
/// then the interval timer is cleaned up (no orphan timers).
|
||||
#[test]
|
||||
fn interval_timer_cleaned_up_when_actor_dies() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let _inbox = rt.new_inbox::<Ping>().unwrap();
|
||||
|
||||
// Heartbeat sends to a counter that we'll kill
|
||||
let counter_addr = rt.spawn(CounterActor { count: 0 }).unwrap();
|
||||
|
||||
// HeartbeatActor sends Ping to counter every tick
|
||||
let _hb = rt.spawn(HeartbeatActor {
|
||||
target: counter_addr,
|
||||
period: 1,
|
||||
}).unwrap();
|
||||
|
||||
// Let it run a few ticks
|
||||
for _ in 0..3 { rt.tick(); }
|
||||
|
||||
// Stop the counter
|
||||
rt.stop_actor(counter_addr).unwrap();
|
||||
for _ in 0..5 { rt.tick(); }
|
||||
|
||||
// Counter is gone, interval timer should be GC'd.
|
||||
let stats = rt.stats();
|
||||
// Only the heartbeat actor should remain
|
||||
assert_eq!(stats.workers[0].num_actors, 1);
|
||||
}
|
||||
|
||||
/// Given a timer with delay 0,
|
||||
/// when the next tick fires,
|
||||
/// then the message is delivered immediately on the next tick.
|
||||
#[test]
|
||||
fn timer_with_zero_delay_fires_next_tick() {
|
||||
let rt = std_runtime(RuntimeConfig::default());
|
||||
let inbox = rt.new_inbox::<Ping>().unwrap();
|
||||
|
||||
let _timer_actor = rt.spawn(TimerStartActor {
|
||||
target: *inbox.addr(),
|
||||
delay_ticks: 0,
|
||||
}).unwrap();
|
||||
|
||||
rt.tick(); // on_start schedules timer with delay=0
|
||||
// Timer requests are processed after tick_all (phase 5.5)
|
||||
// Timer fires on the NEXT tick (phase 2.5)
|
||||
assert!(inbox.try_recv().is_none(), "not yet -- timer fires next tick");
|
||||
|
||||
rt.tick(); // timer fires
|
||||
assert!(inbox.try_recv().is_some(), "zero-delay timer fires on next tick");
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ use std::sync::Arc;
|
|||
|
||||
use swactor::actor::{ActorAddress, ActorExited, ActorInterface, ExitReason};
|
||||
use swactor::runtime::{Ctx, Runtime, RuntimeConfig};
|
||||
use swactor_std::{CtxWatching, RuntimeWatching, StdExtension};
|
||||
|
||||
// ── Actors ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -114,13 +115,18 @@ fn tick_n(rt: &Runtime, n: usize) {
|
|||
}
|
||||
}
|
||||
|
||||
fn single_thread_config() -> RuntimeConfig {
|
||||
fn watch_config() -> RuntimeConfig {
|
||||
RuntimeConfig {
|
||||
num_threads: 1,
|
||||
..RuntimeConfig::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn watch_runtime() -> Runtime {
|
||||
Runtime::new(watch_config())
|
||||
.with_extension(Arc::new(StdExtension::new()))
|
||||
}
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Given a watcher and a target actor,
|
||||
|
|
@ -128,7 +134,7 @@ fn single_thread_config() -> RuntimeConfig {
|
|||
/// then the watcher's on_actor_exit fires with ExitReason::Panicked.
|
||||
#[test]
|
||||
fn watch_receives_notification_on_panic() {
|
||||
let rt = Runtime::new(single_thread_config());
|
||||
let rt = watch_runtime();
|
||||
let (watcher_actor, state) = ExitWatcher::new();
|
||||
|
||||
let target = rt.spawn(PanicOnCommand).unwrap();
|
||||
|
|
@ -152,7 +158,7 @@ fn watch_receives_notification_on_panic() {
|
|||
/// then the watcher receives NO notification.
|
||||
#[test]
|
||||
fn unwatch_prevents_notification() {
|
||||
let rt = Runtime::new(single_thread_config());
|
||||
let rt = watch_runtime();
|
||||
let (watcher_actor, state) = ExitWatcher::new();
|
||||
|
||||
let target = rt.spawn(PanicOnCommand).unwrap();
|
||||
|
|
@ -173,56 +179,27 @@ fn unwatch_prevents_notification() {
|
|||
assert_eq!(state.count(), 0, "after unwatch, no notification should be delivered");
|
||||
}
|
||||
|
||||
/// Given a watch on an address that was never spawned,
|
||||
/// then the watcher receives ActorExited { reason: Stopped }.
|
||||
#[test]
|
||||
fn watch_nonexistent_actor_delivers_stopped() {
|
||||
let rt = Runtime::new(single_thread_config());
|
||||
let (watcher_actor, state) = ExitWatcher::new();
|
||||
|
||||
let watcher = rt.spawn(watcher_actor).unwrap();
|
||||
|
||||
let nonexistent = ActorAddress::new_random();
|
||||
rt.send_to(watcher, WatcherCmd::WatchThis(nonexistent)).unwrap();
|
||||
tick_n(&rt, 5);
|
||||
|
||||
assert_eq!(state.count(), 1, "should receive ActorExited for non-existent target");
|
||||
assert_eq!(state.last_reason(), Some(ExitReason::Stopped));
|
||||
assert_eq!(state.last_addr(), Some(nonexistent));
|
||||
}
|
||||
|
||||
/// Given a watcher that dies before the target,
|
||||
/// when the target subsequently panics,
|
||||
/// then there is no panic or leak.
|
||||
#[test]
|
||||
fn watcher_dies_before_target_no_panic() {
|
||||
let rt = Runtime::new(single_thread_config());
|
||||
let rt = watch_runtime();
|
||||
|
||||
let target = rt.spawn(PanicOnCommand).unwrap();
|
||||
let (watcher_actor, _state) = ExitWatcher::new();
|
||||
let watcher = rt.spawn(watcher_actor).unwrap();
|
||||
let target2 = rt.spawn(PanicOnCommand).unwrap();
|
||||
|
||||
// Watch
|
||||
rt.send_to(watcher, WatcherCmd::WatchThis(target)).unwrap();
|
||||
// Watch via runtime-level API
|
||||
rt.watch(target2, target);
|
||||
tick_n(&rt, 3);
|
||||
|
||||
// Kill the watcher first (send it a type-mismatched panic msg directly)
|
||||
// Actually, ExitWatcher doesn't panic. Use Runtime-level watch + PanicOnCommand.
|
||||
let rt2 = Runtime::new(single_thread_config());
|
||||
let target2 = rt2.spawn(PanicOnCommand).unwrap();
|
||||
let watcher2 = rt2.spawn(PanicOnCommand).unwrap();
|
||||
|
||||
use swactor::actor::ContextInner;
|
||||
rt2.watch(watcher2, target2);
|
||||
tick_n(&rt2, 3);
|
||||
|
||||
// Kill watcher first
|
||||
rt2.send_to(watcher2, PanicMsg).unwrap();
|
||||
tick_n(&rt2, 5);
|
||||
// Kill watcher (target2) first
|
||||
rt.send_to(target2, PanicMsg).unwrap();
|
||||
tick_n(&rt, 5);
|
||||
|
||||
// Kill target — should not crash
|
||||
rt2.send_to(target2, PanicMsg).unwrap();
|
||||
tick_n(&rt2, 5);
|
||||
rt.send_to(target, PanicMsg).unwrap();
|
||||
tick_n(&rt, 5);
|
||||
|
||||
// If we got here, no crash.
|
||||
}
|
||||
|
|
@ -232,7 +209,7 @@ fn watcher_dies_before_target_no_panic() {
|
|||
/// then the watcher receives exactly one notification.
|
||||
#[test]
|
||||
fn idempotent_watch_delivers_one_notification() {
|
||||
let rt = Runtime::new(single_thread_config());
|
||||
let rt = watch_runtime();
|
||||
let (watcher_actor, state) = ExitWatcher::new();
|
||||
|
||||
let target = rt.spawn(PanicOnCommand).unwrap();
|
||||
|
|
@ -256,7 +233,7 @@ fn idempotent_watch_delivers_one_notification() {
|
|||
/// then all watchers receive the notification.
|
||||
#[test]
|
||||
fn multiple_watchers_all_notified() {
|
||||
let rt = Runtime::new(single_thread_config());
|
||||
let rt = watch_runtime();
|
||||
let (w1_actor, s1) = ExitWatcher::new();
|
||||
let (w2_actor, s2) = ExitWatcher::new();
|
||||
let (w3_actor, s3) = ExitWatcher::new();
|
||||
|
|
@ -282,7 +259,7 @@ fn multiple_watchers_all_notified() {
|
|||
/// Self-watch doesn't crash the runtime.
|
||||
#[test]
|
||||
fn self_watch_does_not_crash() {
|
||||
let rt = Runtime::new(single_thread_config());
|
||||
let rt = watch_runtime();
|
||||
let (watcher_actor, _state) = ExitWatcher::new();
|
||||
|
||||
let actor = rt.spawn(watcher_actor).unwrap();
|
||||
|
|
@ -295,14 +272,13 @@ fn self_watch_does_not_crash() {
|
|||
/// Runtime-level watch (outside actor context) delivers notification.
|
||||
#[test]
|
||||
fn runtime_level_watch_delivers_notification() {
|
||||
let rt = Runtime::new(single_thread_config());
|
||||
let rt = watch_runtime();
|
||||
let (watcher_actor, state) = ExitWatcher::new();
|
||||
|
||||
let target = rt.spawn(PanicOnCommand).unwrap();
|
||||
let watcher = rt.spawn(watcher_actor).unwrap();
|
||||
tick_n(&rt, 2); // ensure both spawned
|
||||
|
||||
use swactor::actor::ContextInner;
|
||||
rt.watch(watcher, target);
|
||||
|
||||
rt.send_to(target, PanicMsg).unwrap();
|
||||
|
|
@ -312,31 +288,12 @@ fn runtime_level_watch_delivers_notification() {
|
|||
assert_eq!(state.last_reason(), Some(ExitReason::Panicked));
|
||||
}
|
||||
|
||||
/// Runtime-level watch on non-existent address delivers Stopped.
|
||||
#[test]
|
||||
fn runtime_level_watch_nonexistent_delivers_stopped() {
|
||||
let rt = Runtime::new(single_thread_config());
|
||||
let (watcher_actor, state) = ExitWatcher::new();
|
||||
|
||||
let watcher = rt.spawn(watcher_actor).unwrap();
|
||||
tick_n(&rt, 2);
|
||||
|
||||
let fake = ActorAddress::new_random();
|
||||
use swactor::actor::ContextInner;
|
||||
rt.watch(watcher, fake);
|
||||
|
||||
tick_n(&rt, 5);
|
||||
|
||||
assert_eq!(state.count(), 1, "watching non-existent from runtime should deliver Stopped");
|
||||
assert_eq!(state.last_reason(), Some(ExitReason::Stopped));
|
||||
}
|
||||
|
||||
/// Given a watcher watching target via on_actor_exit,
|
||||
/// when target panics,
|
||||
/// then the watcher can react by spawning a replacement (supervision pattern).
|
||||
#[test]
|
||||
fn watcher_can_react_to_death_by_spawning() {
|
||||
let rt = Runtime::new(single_thread_config());
|
||||
let rt = watch_runtime();
|
||||
let spawned = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
struct Supervisor {
|
||||
|
|
|
|||
181
tools/fn_complexity.py
Executable file
181
tools/fn_complexity.py
Executable file
|
|
@ -0,0 +1,181 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Analyze per-function complexity metrics for Rust source files.
|
||||
|
||||
Reports: function name, line count, max nesting depth, and file location.
|
||||
Sorted by line count (descending) to surface the largest functions first.
|
||||
|
||||
Usage:
|
||||
python3 tools/fn_complexity.py src/worker.rs
|
||||
python3 tools/fn_complexity.py src/ # recurse into directory
|
||||
python3 tools/fn_complexity.py src/ --json # JSON output
|
||||
python3 tools/fn_complexity.py src/ --min-lines 20 # filter small fns
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass, asdict
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class FnMetric:
|
||||
file: str
|
||||
name: str
|
||||
start_line: int
|
||||
end_line: int
|
||||
lines: int
|
||||
max_depth: int
|
||||
has_unsafe: bool
|
||||
|
||||
@property
|
||||
def location(self) -> str:
|
||||
return f"{self.file}:{self.start_line}"
|
||||
|
||||
|
||||
# Matches fn declarations (free functions, methods, trait impls)
|
||||
FN_PATTERN = re.compile(
|
||||
r'^\s*(?:pub(?:\(crate\))?\s+)?(?:async\s+)?fn\s+(\w+)'
|
||||
)
|
||||
|
||||
# Matches impl blocks to qualify method names
|
||||
IMPL_PATTERN = re.compile(
|
||||
r'^\s*impl(?:<[^>]*>)?\s+(?:(\w+(?:<[^>]*>)?)\s+for\s+)?(\w+)'
|
||||
)
|
||||
|
||||
|
||||
def analyze_file(path: str) -> list[FnMetric]:
|
||||
"""Parse a single Rust file and extract function metrics."""
|
||||
with open(path) as f:
|
||||
lines = f.readlines()
|
||||
|
||||
metrics = []
|
||||
current_impl = None
|
||||
brace_depth = 0
|
||||
fn_stack: list[tuple[str, int, int, bool]] = [] # (name, start_line, start_depth, has_unsafe)
|
||||
|
||||
for i, line in enumerate(lines, 1):
|
||||
stripped = line.rstrip()
|
||||
|
||||
# Track impl blocks for method qualification
|
||||
impl_match = IMPL_PATTERN.match(stripped)
|
||||
if impl_match and '{' in stripped:
|
||||
trait_name = impl_match.group(1)
|
||||
type_name = impl_match.group(2)
|
||||
if trait_name:
|
||||
current_impl = f"{trait_name} for {type_name}"
|
||||
else:
|
||||
current_impl = type_name
|
||||
|
||||
# Detect function start
|
||||
fn_match = FN_PATTERN.match(stripped)
|
||||
if fn_match and '{' in stripped:
|
||||
fn_name = fn_match.group(1)
|
||||
if current_impl:
|
||||
fn_name = f"{current_impl}::{fn_name}"
|
||||
has_unsafe = 'unsafe' in stripped
|
||||
fn_stack.append((fn_name, i, brace_depth, has_unsafe))
|
||||
|
||||
# Track brace depth
|
||||
# Simple brace counting (ignores braces in strings/comments, good enough)
|
||||
opens = stripped.count('{')
|
||||
closes = stripped.count('}')
|
||||
brace_depth += opens - closes
|
||||
|
||||
# Check for unsafe blocks within functions
|
||||
if fn_stack and 'unsafe' in stripped and fn_match is None:
|
||||
name, start, depth, _ = fn_stack[-1]
|
||||
fn_stack[-1] = (name, start, depth, True)
|
||||
|
||||
# When a function's brace depth returns to entry level, it's done
|
||||
while fn_stack and brace_depth <= fn_stack[-1][2]:
|
||||
fn_name, start_line, _, has_unsafe = fn_stack.pop()
|
||||
end_line = i
|
||||
fn_lines = end_line - start_line + 1
|
||||
|
||||
# Calculate max nesting depth within this function
|
||||
max_depth = 0
|
||||
local_depth = 0
|
||||
for j in range(start_line - 1, end_line):
|
||||
if j < len(lines):
|
||||
local_depth += lines[j].count('{') - lines[j].count('}')
|
||||
max_depth = max(max_depth, local_depth)
|
||||
|
||||
# Reset impl context if we've left the impl block
|
||||
if brace_depth == 0:
|
||||
current_impl = None
|
||||
|
||||
metrics.append(FnMetric(
|
||||
file=path,
|
||||
name=fn_name,
|
||||
start_line=start_line,
|
||||
end_line=end_line,
|
||||
lines=fn_lines,
|
||||
max_depth=max_depth,
|
||||
has_unsafe=has_unsafe,
|
||||
))
|
||||
|
||||
return metrics
|
||||
|
||||
|
||||
def collect_files(path: str) -> list[str]:
|
||||
"""Collect .rs files from a path (file or directory)."""
|
||||
p = Path(path)
|
||||
if p.is_file():
|
||||
return [str(p)]
|
||||
elif p.is_dir():
|
||||
return sorted(str(f) for f in p.rglob('*.rs'))
|
||||
else:
|
||||
print(f"Error: {path} is not a file or directory", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Rust function complexity analyzer')
|
||||
parser.add_argument('paths', nargs='+', help='Rust source files or directories')
|
||||
parser.add_argument('--json', action='store_true', help='Output as JSON')
|
||||
parser.add_argument('--min-lines', type=int, default=0,
|
||||
help='Only show functions with at least N lines')
|
||||
parser.add_argument('--top', type=int, default=0,
|
||||
help='Show only the top N largest functions')
|
||||
args = parser.parse_args()
|
||||
|
||||
all_metrics: list[FnMetric] = []
|
||||
for path in args.paths:
|
||||
for file in collect_files(path):
|
||||
all_metrics.extend(analyze_file(file))
|
||||
|
||||
# Filter and sort
|
||||
if args.min_lines:
|
||||
all_metrics = [m for m in all_metrics if m.lines >= args.min_lines]
|
||||
all_metrics.sort(key=lambda m: m.lines, reverse=True)
|
||||
if args.top:
|
||||
all_metrics = all_metrics[:args.top]
|
||||
|
||||
if args.json:
|
||||
output = [asdict(m) for m in all_metrics]
|
||||
print(json.dumps(output, indent=2))
|
||||
else:
|
||||
# Summary stats
|
||||
if all_metrics:
|
||||
total_fns = len(all_metrics)
|
||||
avg_lines = sum(m.lines for m in all_metrics) / total_fns
|
||||
max_fn = all_metrics[0]
|
||||
|
||||
print(f"Functions: {total_fns}, avg lines: {avg_lines:.1f}, "
|
||||
f"largest: {max_fn.name} ({max_fn.lines} lines)")
|
||||
print()
|
||||
|
||||
# Table output
|
||||
print(f"{'Lines':>5} {'Depth':>5} {'Location':<45} {'Function'}")
|
||||
print(f"{'─'*5} {'─'*5} {'─'*45} {'─'*40}")
|
||||
for m in all_metrics:
|
||||
loc = f"{m.file}:{m.start_line}"
|
||||
unsafe_marker = " [unsafe]" if m.has_unsafe else ""
|
||||
print(f"{m.lines:>5} {m.max_depth:>5} {loc:<45} {m.name}{unsafe_marker}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
164
tools/loc_analysis.py
Executable file
164
tools/loc_analysis.py
Executable file
|
|
@ -0,0 +1,164 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Analyze line-of-code breakdown for Rust source files.
|
||||
|
||||
Reports: logic, comments, blank, and string-literal lines per file.
|
||||
Helps track code reduction progress and identify embedded content.
|
||||
|
||||
Usage:
|
||||
python3 tools/loc_analysis.py src/
|
||||
python3 tools/loc_analysis.py src/ crates/std/src/ --json
|
||||
python3 tools/loc_analysis.py src/ --sort-by logic
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass, asdict
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileMetrics:
|
||||
file: str
|
||||
total: int
|
||||
logic: int
|
||||
comment: int
|
||||
blank: int
|
||||
string_literal: int
|
||||
|
||||
@property
|
||||
def logic_pct(self) -> float:
|
||||
return (self.logic / self.total * 100) if self.total else 0.0
|
||||
|
||||
|
||||
def analyze_file(path: str) -> FileMetrics:
|
||||
"""Count line types in a Rust source file."""
|
||||
with open(path) as f:
|
||||
lines = f.readlines()
|
||||
|
||||
total = len(lines)
|
||||
blank = 0
|
||||
comment = 0
|
||||
string_lit = 0
|
||||
logic = 0
|
||||
|
||||
in_block_comment = False
|
||||
in_raw_string = False
|
||||
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
|
||||
if not stripped:
|
||||
blank += 1
|
||||
continue
|
||||
|
||||
# Track block comments
|
||||
if in_block_comment:
|
||||
comment += 1
|
||||
if '*/' in stripped:
|
||||
in_block_comment = False
|
||||
continue
|
||||
|
||||
if stripped.startswith('/*'):
|
||||
comment += 1
|
||||
if '*/' not in stripped:
|
||||
in_block_comment = True
|
||||
continue
|
||||
|
||||
# Line comments
|
||||
if stripped.startswith('//'):
|
||||
comment += 1
|
||||
continue
|
||||
|
||||
# Raw string literals (r#"..."#, r##"..."##, etc.) and regular strings
|
||||
# Heuristic: line is predominantly a string if it's inside a raw string
|
||||
# or contains a long string literal (>60 chars of quoted content)
|
||||
if in_raw_string:
|
||||
string_lit += 1
|
||||
if '"#' in stripped or '"##' in stripped:
|
||||
in_raw_string = False
|
||||
continue
|
||||
|
||||
if 'r#"' in stripped or 'r##"' in stripped:
|
||||
if '"#' not in stripped.split('r#"', 1)[-1] if 'r#"' in stripped else True:
|
||||
in_raw_string = True
|
||||
string_lit += 1
|
||||
continue
|
||||
|
||||
# Heuristic: if the line has a long string literal, count it
|
||||
string_content = re.findall(r'"([^"]*)"', stripped)
|
||||
total_string_chars = sum(len(s) for s in string_content)
|
||||
if total_string_chars > 60:
|
||||
string_lit += 1
|
||||
else:
|
||||
logic += 1
|
||||
|
||||
return FileMetrics(
|
||||
file=path,
|
||||
total=total,
|
||||
logic=logic,
|
||||
comment=comment,
|
||||
blank=blank,
|
||||
string_literal=string_lit,
|
||||
)
|
||||
|
||||
|
||||
def collect_files(path: str) -> list[str]:
|
||||
"""Collect .rs files from a path (file or directory)."""
|
||||
p = Path(path)
|
||||
if p.is_file():
|
||||
return [str(p)]
|
||||
elif p.is_dir():
|
||||
return sorted(str(f) for f in p.rglob('*.rs'))
|
||||
else:
|
||||
print(f"Error: {path} is not a file or directory", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Rust LOC breakdown analyzer')
|
||||
parser.add_argument('paths', nargs='+', help='Rust source files or directories')
|
||||
parser.add_argument('--json', action='store_true', help='Output as JSON')
|
||||
parser.add_argument('--sort-by', choices=['total', 'logic', 'comment', 'string_literal'],
|
||||
default='total', help='Sort column')
|
||||
args = parser.parse_args()
|
||||
|
||||
all_metrics: list[FileMetrics] = []
|
||||
for path in args.paths:
|
||||
for file in collect_files(path):
|
||||
all_metrics.extend([analyze_file(file)])
|
||||
|
||||
all_metrics.sort(key=lambda m: getattr(m, args.sort_by), reverse=True)
|
||||
|
||||
if args.json:
|
||||
print(json.dumps([asdict(m) for m in all_metrics], indent=2))
|
||||
else:
|
||||
# Summary
|
||||
totals = FileMetrics(
|
||||
file="TOTAL",
|
||||
total=sum(m.total for m in all_metrics),
|
||||
logic=sum(m.logic for m in all_metrics),
|
||||
comment=sum(m.comment for m in all_metrics),
|
||||
blank=sum(m.blank for m in all_metrics),
|
||||
string_literal=sum(m.string_literal for m in all_metrics),
|
||||
)
|
||||
|
||||
print(f"Files: {len(all_metrics)}, Total: {totals.total}, "
|
||||
f"Logic: {totals.logic} ({totals.logic_pct:.1f}%), "
|
||||
f"Comment: {totals.comment}, Blank: {totals.blank}, "
|
||||
f"Strings: {totals.string_literal}")
|
||||
print()
|
||||
|
||||
print(f"{'Total':>6} {'Logic':>6} {'Cmt':>5} {'Blank':>5} {'Str':>5} {'%Logic':>6} {'File'}")
|
||||
print(f"{'─'*6} {'─'*6} {'─'*5} {'─'*5} {'─'*5} {'─'*6} {'─'*45}")
|
||||
for m in all_metrics:
|
||||
print(f"{m.total:>6} {m.logic:>6} {m.comment:>5} {m.blank:>5} "
|
||||
f"{m.string_literal:>5} {m.logic_pct:>5.1f}% {m.file}")
|
||||
print(f"{'─'*6} {'─'*6} {'─'*5} {'─'*5} {'─'*5} {'─'*6} {'─'*45}")
|
||||
print(f"{totals.total:>6} {totals.logic:>6} {totals.comment:>5} {totals.blank:>5} "
|
||||
f"{totals.string_literal:>5} {totals.logic_pct:>5.1f}% TOTAL")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Loading…
Reference in a new issue