Compare commits
13 commits
a24d5415eb
...
d9489ccac2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9489ccac2 | ||
|
|
41af749bb4 | ||
|
|
5a8aeaf428 | ||
|
|
d370a41cf1 | ||
|
|
e21a4ae19c | ||
|
|
133314903a | ||
|
|
803289c7dc | ||
|
|
33df021afd | ||
|
|
edea0aac2a | ||
|
|
00e1fce80d | ||
|
|
09d5c75c39 | ||
|
|
93b1b1fa1e | ||
| 1169f0322d |
49 changed files with 10159 additions and 9280 deletions
|
|
@ -722,6 +722,66 @@ fn registry_benchmarks(c: &mut Criterion) {
|
||||||
group.finish();
|
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!(
|
criterion_group!(
|
||||||
benches,
|
benches,
|
||||||
latency_benchmarks,
|
latency_benchmarks,
|
||||||
|
|
@ -731,5 +791,6 @@ criterion_group!(
|
||||||
contention_benchmarks,
|
contention_benchmarks,
|
||||||
placement_benchmarks,
|
placement_benchmarks,
|
||||||
registry_benchmarks,
|
registry_benchmarks,
|
||||||
|
allocation_benchmarks,
|
||||||
);
|
);
|
||||||
criterion_main!(benches);
|
criterion_main!(benches);
|
||||||
|
|
|
||||||
403
crates/runtime-dashboard/src/actor_detail_html.rs
Normal file
403
crates/runtime-dashboard/src/actor_detail_html.rs
Normal file
|
|
@ -0,0 +1,403 @@
|
||||||
|
pub const ACTOR_DETAIL_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>Actor Detail — Swactor 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; }
|
||||||
|
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
.content { padding: 16px 20px; max-width: 900px; }
|
||||||
|
|
||||||
|
.breadcrumb { color: #555; font-size: 12px; margin-bottom: 12px; }
|
||||||
|
.breadcrumb a { color: #888; text-decoration: none; }
|
||||||
|
.breadcrumb a:hover { color: #e0e0e0; }
|
||||||
|
|
||||||
|
.info-card {
|
||||||
|
background: #161822; border: 1px solid #2a2d3e; border-radius: 6px;
|
||||||
|
padding: 16px; margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.info-row { display: flex; gap: 24px; margin-bottom: 6px; flex-wrap: wrap; }
|
||||||
|
.info-label { color: #888; font-size: 11px; text-transform: uppercase; }
|
||||||
|
.info-value { color: #fff; font-weight: 700; font-size: 15px; }
|
||||||
|
.info-value.healthy { color: #4caf50; }
|
||||||
|
.info-value.poisoned { color: #f44336; }
|
||||||
|
|
||||||
|
.stats-cards {
|
||||||
|
display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.stat-card {
|
||||||
|
background: #161822; border: 1px solid #2a2d3e; border-radius: 4px;
|
||||||
|
padding: 12px; 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; }
|
||||||
|
|
||||||
|
.sparkline-panel {
|
||||||
|
background: #161822; border: 1px solid #2a2d3e; border-radius: 6px;
|
||||||
|
padding: 14px; margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.sparkline-panel h3 { font-size: 11px; color: #888; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; }
|
||||||
|
.sparkline-panel svg { width: 100%; height: 50px; }
|
||||||
|
|
||||||
|
.type-breakdown {
|
||||||
|
background: #161822; border: 1px solid #2a2d3e; border-radius: 6px;
|
||||||
|
padding: 14px; margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.type-breakdown h3 { font-size: 11px; color: #888; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; }
|
||||||
|
.type-row { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; font-size: 11px; }
|
||||||
|
.type-name { color: #e0e0e0; min-width: 180px; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; }
|
||||||
|
.type-bar-bg { flex: 1; background: #1e2030; height: 14px; border-radius: 2px; overflow: hidden; }
|
||||||
|
.type-bar-fill { height: 100%; border-radius: 2px; }
|
||||||
|
.type-count { color: #888; min-width: 60px; text-align: right; }
|
||||||
|
.type-pct { color: #555; min-width: 40px; text-align: right; }
|
||||||
|
|
||||||
|
.logs-panel {
|
||||||
|
background: #161822; border: 1px solid #2a2d3e; border-radius: 6px;
|
||||||
|
padding: 14px; margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.logs-panel h3 {
|
||||||
|
font-size: 11px; color: #888; text-transform: uppercase; letter-spacing: 1px;
|
||||||
|
margin-bottom: 8px; display: flex; align-items: center; gap: 12px;
|
||||||
|
}
|
||||||
|
.level-filter { display: flex; gap: 4px; }
|
||||||
|
.level-btn {
|
||||||
|
background: #1e2030; border: 1px solid #2a2d3e; border-radius: 3px;
|
||||||
|
color: #888; font-size: 10px; padding: 1px 6px; cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.level-btn.active { border-color: #555; color: #fff; }
|
||||||
|
.level-btn.error { color: #f44336; }
|
||||||
|
.level-btn.warn { color: #ff9800; }
|
||||||
|
.level-btn.info { color: #2196f3; }
|
||||||
|
.level-btn.debug { color: #888; }
|
||||||
|
.level-btn.trace { color: #555; }
|
||||||
|
|
||||||
|
.log-list {
|
||||||
|
max-height: 400px; overflow-y: auto; font-size: 11px; line-height: 1.6;
|
||||||
|
}
|
||||||
|
.log-entry { display: flex; gap: 8px; padding: 1px 0; border-bottom: 1px solid #1a1c2e; }
|
||||||
|
.log-time { color: #555; white-space: nowrap; min-width: 80px; }
|
||||||
|
.log-level { font-weight: 700; min-width: 50px; }
|
||||||
|
.log-level.ERROR { color: #f44336; }
|
||||||
|
.log-level.WARN { color: #ff9800; }
|
||||||
|
.log-level.INFO { color: #2196f3; }
|
||||||
|
.log-level.DEBUG { color: #888; }
|
||||||
|
.log-level.TRACE { color: #555; }
|
||||||
|
.log-msg { color: #e0e0e0; word-break: break-all; }
|
||||||
|
|
||||||
|
::-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>
|
||||||
|
</h1>
|
||||||
|
<nav class="nav-links">
|
||||||
|
<a href="/" class="nav-link">Overview</a>
|
||||||
|
<a href="/actors" class="nav-link">Actors</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<div class="breadcrumb">
|
||||||
|
<a href="/">Overview</a> / <a href="/actors">Actors</a> / <span id="addrBreadcrumb">—</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-card">
|
||||||
|
<div class="info-row">
|
||||||
|
<div><div class="info-label">Address</div><div class="info-value" id="addrFull">—</div></div>
|
||||||
|
<div><div class="info-label">Worker</div><div class="info-value" id="addrWorker">—</div></div>
|
||||||
|
<div><div class="info-label">Status</div><div class="info-value" id="addrStatus">—</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<div><div class="info-label">Last Message Type</div><div class="info-value" id="addrLastMsg" style="color:#4caf50;font-size:13px;">—</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-cards">
|
||||||
|
<div class="stat-card"><div class="value" id="addrMsgs">0</div><div class="label">Messages</div></div>
|
||||||
|
<div class="stat-card"><div class="value" id="addrMailbox">0</div><div class="label">Mailbox</div></div>
|
||||||
|
<div class="stat-card"><div class="value" id="addrRate">0</div><div class="label">Msg/s</div></div>
|
||||||
|
<div class="stat-card"><div class="value" id="addrWorkerLoad">—</div><div class="label">Worker Load</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sparkline-panel">
|
||||||
|
<h3>Message Rate</h3>
|
||||||
|
<svg id="rateSpark" viewBox="0 0 400 50" preserveAspectRatio="none"></svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sparkline-panel">
|
||||||
|
<h3>Mailbox Depth</h3>
|
||||||
|
<svg id="mboxSpark" viewBox="0 0 400 50" preserveAspectRatio="none"></svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="type-breakdown" id="typeBreakdown" style="display:none;">
|
||||||
|
<h3>Message Types</h3>
|
||||||
|
<div id="typeRows"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="logs-panel">
|
||||||
|
<h3>
|
||||||
|
<span>Logs</span>
|
||||||
|
<span id="logCount" style="color:#555;">(0)</span>
|
||||||
|
<div class="level-filter">
|
||||||
|
<button class="level-btn error active" data-level="ERROR" onclick="toggleLevel(this)">ERR</button>
|
||||||
|
<button class="level-btn warn active" data-level="WARN" onclick="toggleLevel(this)">WARN</button>
|
||||||
|
<button class="level-btn info active" data-level="INFO" onclick="toggleLevel(this)">INFO</button>
|
||||||
|
<button class="level-btn debug active" data-level="DEBUG" onclick="toggleLevel(this)">DBG</button>
|
||||||
|
<button class="level-btn trace active" data-level="TRACE" onclick="toggleLevel(this)">TRC</button>
|
||||||
|
</div>
|
||||||
|
</h3>
|
||||||
|
<div class="log-list" id="logList"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var targetAddr = '__ACTOR_ADDR__';
|
||||||
|
var dot = document.getElementById('statusDot');
|
||||||
|
|
||||||
|
var history = { rates: [], mailbox: [], prev_msgs: 0 };
|
||||||
|
|
||||||
|
function formatAddr(addr) {
|
||||||
|
if (!addr) return '';
|
||||||
|
var bytes = Array.isArray(addr) ? addr : Object.values(addr);
|
||||||
|
var hex = '';
|
||||||
|
for (var i = 0; i < bytes.length; i++) {
|
||||||
|
hex += ('0' + bytes[i].toString(16)).slice(-2);
|
||||||
|
}
|
||||||
|
return hex;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortAddr(hex) {
|
||||||
|
return hex.length > 16 ? hex.substring(0, 16) + '\u2026' : hex;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortTypeName(full) {
|
||||||
|
if (!full) return '\u2014';
|
||||||
|
var parts = full.split('::');
|
||||||
|
return parts[parts.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSparklineSvg(svgEl, data, color) {
|
||||||
|
if (!data || data.length < 2) { svgEl.innerHTML = ''; return; }
|
||||||
|
var max = Math.max.apply(null, data);
|
||||||
|
if (max === 0) max = 1;
|
||||||
|
var w = 400, h = 50;
|
||||||
|
var step = w / (data.length - 1);
|
||||||
|
var points = data.map(function(v, i) {
|
||||||
|
return (i * step).toFixed(1) + ',' + (h - (v / max) * (h - 4) - 2).toFixed(1);
|
||||||
|
}).join(' ');
|
||||||
|
svgEl.innerHTML = '<polyline fill="none" stroke="' + color + '" stroke-width="2" points="' + points + '"/>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function findActor(stats) {
|
||||||
|
if (!stats.actor_details) return null;
|
||||||
|
for (var i = 0; i < stats.actor_details.length; i++) {
|
||||||
|
var a = stats.actor_details[i];
|
||||||
|
var hex = formatAddr(a.address);
|
||||||
|
if (hex === targetAddr || hex.indexOf(targetAddr) === 0) return a;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDetail(stats) {
|
||||||
|
var actor = findActor(stats);
|
||||||
|
if (!actor) return;
|
||||||
|
|
||||||
|
var hex = formatAddr(actor.address);
|
||||||
|
document.getElementById('addrBreadcrumb').textContent = shortAddr(hex);
|
||||||
|
document.getElementById('addrFull').textContent = hex;
|
||||||
|
document.getElementById('addrWorker').textContent = 'W' + actor.worker_id;
|
||||||
|
|
||||||
|
var statusEl = document.getElementById('addrStatus');
|
||||||
|
if (actor.poisoned) {
|
||||||
|
statusEl.textContent = 'POISONED';
|
||||||
|
statusEl.className = 'info-value poisoned';
|
||||||
|
} else {
|
||||||
|
statusEl.textContent = 'Healthy';
|
||||||
|
statusEl.className = 'info-value healthy';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('addrLastMsg').textContent = shortTypeName(actor.last_msg_type);
|
||||||
|
document.getElementById('addrMsgs').textContent = actor.messages_processed.toLocaleString();
|
||||||
|
document.getElementById('addrMailbox').textContent = actor.mailbox_depth;
|
||||||
|
|
||||||
|
// Compute rate
|
||||||
|
var rate = actor.messages_processed - history.prev_msgs;
|
||||||
|
if (rate < 0) rate = 0;
|
||||||
|
history.prev_msgs = actor.messages_processed;
|
||||||
|
history.rates.push(rate);
|
||||||
|
history.mailbox.push(actor.mailbox_depth);
|
||||||
|
if (history.rates.length > 300) { history.rates.shift(); history.mailbox.shift(); }
|
||||||
|
|
||||||
|
// Rate per second (SSE interval is ~200ms, so multiply by 5)
|
||||||
|
document.getElementById('addrRate').textContent = (rate * 5).toLocaleString();
|
||||||
|
|
||||||
|
// Worker load
|
||||||
|
if (stats.workers) {
|
||||||
|
var w = stats.workers.find(function(w) { return w.id === actor.worker_id; });
|
||||||
|
if (w) {
|
||||||
|
document.getElementById('addrWorkerLoad').textContent =
|
||||||
|
w.num_actors + ' actors, mbox ' + w.mailbox_depth;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSparklineSvg(document.getElementById('rateSpark'), history.rates, '#4caf50');
|
||||||
|
updateSparklineSvg(document.getElementById('mboxSpark'), history.mailbox, '#2196f3');
|
||||||
|
|
||||||
|
// Update message type breakdown
|
||||||
|
updateTypeBreakdown(actor.message_type_counts);
|
||||||
|
}
|
||||||
|
|
||||||
|
var typeColors = ['#4caf50','#2196f3','#ff9800','#9c27b0','#00bcd4','#f44336','#ffeb3b','#e91e63'];
|
||||||
|
|
||||||
|
function updateTypeBreakdown(types) {
|
||||||
|
var panel = document.getElementById('typeBreakdown');
|
||||||
|
var container = document.getElementById('typeRows');
|
||||||
|
if (!types || types.length === 0) { panel.style.display = 'none'; return; }
|
||||||
|
panel.style.display = '';
|
||||||
|
var total = 0;
|
||||||
|
for (var i = 0; i < types.length; i++) total += types[i][1];
|
||||||
|
if (total === 0) { panel.style.display = 'none'; return; }
|
||||||
|
|
||||||
|
var html = '';
|
||||||
|
for (var i = 0; i < types.length; i++) {
|
||||||
|
var name = types[i][0];
|
||||||
|
var count = types[i][1];
|
||||||
|
var pct = (count / total * 100).toFixed(1);
|
||||||
|
var barPct = (count / types[0][1] * 100).toFixed(1);
|
||||||
|
var color = typeColors[i % typeColors.length];
|
||||||
|
var shortName = name.split('::').pop();
|
||||||
|
html += '<div class="type-row">' +
|
||||||
|
'<span class="type-name" title="' + escapeHtml(name) + '">' + escapeHtml(shortName) + '</span>' +
|
||||||
|
'<div class="type-bar-bg"><div class="type-bar-fill" style="width:' + barPct + '%;background:' + color + ';"></div></div>' +
|
||||||
|
'<span class="type-count">' + count.toLocaleString() + '</span>' +
|
||||||
|
'<span class="type-pct">' + pct + '%</span>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Logging ─────────────────────────────────────────────────
|
||||||
|
var activeLevels = { ERROR: true, WARN: true, INFO: true, DEBUG: true, TRACE: true };
|
||||||
|
var allLogs = [];
|
||||||
|
var logList = document.getElementById('logList');
|
||||||
|
var logCount = document.getElementById('logCount');
|
||||||
|
var autoScroll = true;
|
||||||
|
|
||||||
|
window.toggleLevel = function(btn) {
|
||||||
|
var lvl = btn.getAttribute('data-level');
|
||||||
|
activeLevels[lvl] = !activeLevels[lvl];
|
||||||
|
btn.classList.toggle('active');
|
||||||
|
renderLogs();
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatLogTime(ms) {
|
||||||
|
var d = new Date(ms);
|
||||||
|
return ('0' + d.getHours()).slice(-2) + ':' +
|
||||||
|
('0' + d.getMinutes()).slice(-2) + ':' +
|
||||||
|
('0' + d.getSeconds()).slice(-2) + '.' +
|
||||||
|
('00' + d.getMilliseconds()).slice(-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLogs() {
|
||||||
|
var visible = allLogs.filter(function(e) { return activeLevels[e.level]; });
|
||||||
|
logCount.textContent = '(' + visible.length + ')';
|
||||||
|
var html = '';
|
||||||
|
for (var i = 0; i < visible.length; i++) {
|
||||||
|
var e = visible[i];
|
||||||
|
html += '<div class="log-entry">' +
|
||||||
|
'<span class="log-time">' + formatLogTime(e.timestamp_ms) + '</span>' +
|
||||||
|
'<span class="log-level ' + e.level + '">' + e.level + '</span>' +
|
||||||
|
'<span class="log-msg">' + escapeHtml(e.message) + '</span>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
logList.innerHTML = html;
|
||||||
|
if (autoScroll) {
|
||||||
|
logList.scrollTop = logList.scrollHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
if (!s) return '';
|
||||||
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLogEntries(events) {
|
||||||
|
for (var i = 0; i < events.length; i++) {
|
||||||
|
var e = events[i];
|
||||||
|
if (!e.actor_addr) continue;
|
||||||
|
// Match if event's actor_addr starts with or contains target
|
||||||
|
var a = e.actor_addr.toLowerCase();
|
||||||
|
if (a.indexOf(targetAddr.toLowerCase()) === 0 || a === targetAddr.toLowerCase()) {
|
||||||
|
allLogs.push(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Keep bounded
|
||||||
|
while (allLogs.length > 500) allLogs.shift();
|
||||||
|
renderLogs();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch initial logs
|
||||||
|
fetch('/api/logs?actor=' + targetAddr + '&limit=200')
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) { if (Array.isArray(data)) addLogEntries(data); })
|
||||||
|
.catch(function() {});
|
||||||
|
|
||||||
|
logList.addEventListener('scroll', function() {
|
||||||
|
autoScroll = (logList.scrollTop + logList.clientHeight >= logList.scrollHeight - 20);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── SSE ────────────────────────────────────────────────────
|
||||||
|
var es = new EventSource('/events');
|
||||||
|
|
||||||
|
es.addEventListener('stats', function(e) {
|
||||||
|
try { updateDetail(JSON.parse(e.data)); } catch(err) { console.error(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener('activity', function(e) {
|
||||||
|
try { addLogEntries(JSON.parse(e.data)); } catch(err) { console.error(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener('done', function() {
|
||||||
|
dot.className = 'status-dot disconnected';
|
||||||
|
es.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
es.onerror = function() { dot.className = 'status-dot disconnected'; };
|
||||||
|
es.onopen = function() { dot.className = 'status-dot'; };
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"##;
|
||||||
794
crates/runtime-dashboard/src/actors.html
Normal file
794
crates/runtime-dashboard/src/actors.html
Normal file
|
|
@ -0,0 +1,794 @@
|
||||||
|
<!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: visible;
|
||||||
|
}
|
||||||
|
.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: 220px; }
|
||||||
|
|
||||||
|
.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; position: fixed; bottom: 12px; right: 12px;
|
||||||
|
width: 420px; max-height: 320px; overflow-y: auto;
|
||||||
|
background: #161822; border: 1px solid #2a2d3e; border-radius: 6px;
|
||||||
|
padding: 14px; z-index: 100; box-shadow: 0 4px 24px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
.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(2, 1fr); gap: 8px; margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.detail-item { background: #1c1f2e; border-radius: 4px; padding: 6px 8px; }
|
||||||
|
.detail-item .d-label { font-size: 10px; color: #888; text-transform: uppercase; }
|
||||||
|
.detail-item .d-value { font-size: 13px; 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: 50px; }
|
||||||
|
|
||||||
|
::-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, type, or worker..." />
|
||||||
|
<select id="workerFilter" class="search-input" style="width:120px;">
|
||||||
|
<option value="">All Workers</option>
|
||||||
|
</select>
|
||||||
|
<select id="statusFilter" class="search-input" style="width:120px;">
|
||||||
|
<option value="">All Status</option>
|
||||||
|
<option value="healthy">Healthy</option>
|
||||||
|
<option value="poisoned">Poisoned</option>
|
||||||
|
</select>
|
||||||
|
<input type="number" id="minDepth" class="search-input" style="width:100px;" placeholder="Min depth" min="0" />
|
||||||
|
<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>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actor detail overlay (fixed position, doesn't affect grid layout) -->
|
||||||
|
<div id="detailPanel" class="detail-panel">
|
||||||
|
<div class="detail-header">
|
||||||
|
<h2 style="font-size:12px;color:#888;text-transform:uppercase;letter-spacing:1px;">Actor <span id="detailAddr" style="color:#aaa;font-weight:400;"></span></h2>
|
||||||
|
<button class="detail-close" id="detailClose">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="detail-grid">
|
||||||
|
<div class="detail-item"><div class="d-label">Address</div><div class="d-value" id="detailFullAddr" style="font-size:10px;"></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</div><div class="d-value" id="detailMailbox"></div></div>
|
||||||
|
<div class="detail-item"><div class="d-label">Messages</div><div class="d-value" id="detailMsgCount"></div></div>
|
||||||
|
<div class="detail-item"><div class="d-label">Last 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>
|
||||||
|
<div style="font-size:10px;color:#888;text-transform:uppercase;letter-spacing:1px;margin-bottom:4px;">Mailbox History</div>
|
||||||
|
<canvas id="sparkline" class="sparkline"></canvas>
|
||||||
|
</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 topPad = 20;
|
||||||
|
var chartH = H - 35;
|
||||||
|
|
||||||
|
for (var i = 0; i < buckets.length; i++) {
|
||||||
|
var x = 20 + i * (barW + 8);
|
||||||
|
var h = (counts[i] / maxCount) * (chartH - topPad);
|
||||||
|
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 topPad = 20;
|
||||||
|
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 - topPad);
|
||||||
|
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 workerFilter = document.getElementById('workerFilter').value;
|
||||||
|
var statusFilter = document.getElementById('statusFilter').value;
|
||||||
|
var minDepthVal = document.getElementById('minDepth').value;
|
||||||
|
var minDepth = minDepthVal ? parseInt(minDepthVal, 10) : 0;
|
||||||
|
|
||||||
|
var filtered = currentActors.filter(function(a) {
|
||||||
|
// Text search
|
||||||
|
if (filter) {
|
||||||
|
var hex = addrToHex(a.address).toLowerCase();
|
||||||
|
var msgType = (a.last_msg_type || '').toLowerCase();
|
||||||
|
if (hex.indexOf(filter) < 0 && ('w' + a.worker_id).indexOf(filter) < 0 && msgType.indexOf(filter) < 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Worker filter
|
||||||
|
if (workerFilter && a.worker_id !== parseInt(workerFilter, 10)) return false;
|
||||||
|
// Status filter
|
||||||
|
if (statusFilter === 'healthy' && a.poisoned) return false;
|
||||||
|
if (statusFilter === 'poisoned' && !a.poisoned) return false;
|
||||||
|
// Min depth
|
||||||
|
if (minDepth > 0 && a.mailbox_depth < minDepth) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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 = 'detail-panel visible';
|
||||||
|
updateDetailPanel();
|
||||||
|
renderActorTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFocus() {
|
||||||
|
focusedAddrHex = null;
|
||||||
|
document.getElementById('detailPanel').className = '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').innerHTML = '<a href="/actor/' + focusedAddrHex + '" style="color:#aaa;text-decoration:none;">' + escapeHtml(addrToHex(actor.address)) + '</a>';
|
||||||
|
document.getElementById('detailFullAddr').innerHTML = '<a href="/actor/' + focusedAddrHex + '" style="color:#fff;text-decoration:none;">' + escapeHtml(focusedAddrHex) + '</a>';
|
||||||
|
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/filter handlers ─────────────────────────────
|
||||||
|
document.getElementById('actorSearch').addEventListener('keyup', function() {
|
||||||
|
clearTimeout(searchTimer);
|
||||||
|
searchTimer = setTimeout(renderActorTable, 150);
|
||||||
|
});
|
||||||
|
document.getElementById('workerFilter').addEventListener('change', renderActorTable);
|
||||||
|
document.getElementById('statusFilter').addEventListener('change', renderActorTable);
|
||||||
|
document.getElementById('minDepth').addEventListener('input', 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];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update worker filter dropdown
|
||||||
|
var wSelect = document.getElementById('workerFilter');
|
||||||
|
var curVal = wSelect.value;
|
||||||
|
var workerIds = {};
|
||||||
|
for (var i = 0; i < currentActors.length; i++) workerIds[currentActors[i].worker_id] = true;
|
||||||
|
var wids = Object.keys(workerIds).sort(function(a,b) { return +a - +b; });
|
||||||
|
wSelect.innerHTML = '<option value="">All Workers</option>';
|
||||||
|
wids.forEach(function(wid) {
|
||||||
|
var opt = document.createElement('option');
|
||||||
|
opt.value = wid;
|
||||||
|
opt.textContent = 'W' + wid;
|
||||||
|
wSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
wSelect.value = curVal;
|
||||||
|
|
||||||
|
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>
|
pub const ACTORS_HTML: &str = include_str\!("actors.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>
|
|
||||||
"##;
|
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,7 @@ impl StatsHook for StatsCollector {
|
||||||
last_msg_type: s.last_msg_type.map(|t| t.to_string()),
|
last_msg_type: s.last_msg_type.map(|t| t.to_string()),
|
||||||
messages_processed: s.messages_processed,
|
messages_processed: s.messages_processed,
|
||||||
poisoned: s.poisoned,
|
poisoned: s.poisoned,
|
||||||
|
message_type_counts: s.message_type_counts.iter().map(|(k, v)| (k.to_string(), *v)).collect(),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
640
crates/runtime-dashboard/src/dashboard.html
Normal file
640
crates/runtime-dashboard/src/dashboard.html
Normal file
|
|
@ -0,0 +1,640 @@
|
||||||
|
<!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; }
|
||||||
|
|
||||||
|
.worker-cards { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.worker-card {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
background: #1c1f2e; border-radius: 4px; padding: 6px 10px;
|
||||||
|
}
|
||||||
|
.worker-card .wc-id { font-weight: 700; min-width: 32px; }
|
||||||
|
.worker-card .wc-bar-wrap { flex: 1; height: 14px; background: #0f1117; border-radius: 2px; overflow: hidden; display: flex; }
|
||||||
|
.worker-card .wc-bar-seg { height: 100%; }
|
||||||
|
.worker-card .wc-stats { font-size: 11px; color: #888; min-width: 200px; text-align: right; }
|
||||||
|
.worker-card .wc-spark { display: inline-flex; gap: 4px; margin-left: 6px; }
|
||||||
|
|
||||||
|
.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-header .sparkline-wrap { display: inline-flex; gap: 8px; margin-left: 12px; }
|
||||||
|
.worker-group-header .sparkline-wrap svg { vertical-align: middle; }
|
||||||
|
.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 id="warningBanner" style="display:none;padding:8px 20px;background:#1c1f2e;border-bottom:1px solid #2a2d3e;font-size:12px;"></div>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="panel chart-panel">
|
||||||
|
<h2>Worker Utilization</h2>
|
||||||
|
<div id="workerCards" class="worker-cards"></div>
|
||||||
|
<div style="margin-top:6px;font-size:10px;color:#555;">
|
||||||
|
<span style="color:#4caf50;">\u25A0</span> processing
|
||||||
|
<span style="color:#2196f3;">\u25A0</span> delivery
|
||||||
|
<span style="color:#00bcd4;">\u25A0</span> spawns
|
||||||
|
<span style="color:#f44336;">\u25A0</span> overhead
|
||||||
|
</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 workerHistory = {}; // { id: { message_rates: [], mailbox_depths: [] } }
|
||||||
|
|
||||||
|
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 colors = ['#4caf50','#2196f3','#ff9800','#f44336','#9c27b0','#00bcd4','#ffeb3b','#e91e63'];
|
||||||
|
|
||||||
|
var phaseColors = ['#4caf50', '#2196f3', '#00bcd4', '#f44336'];
|
||||||
|
// Group tick phases: processing=2, delivery=1+4, spawns=0+3, overhead=5
|
||||||
|
|
||||||
|
function computePhases(timings) {
|
||||||
|
if (!timings || timings.length === 0) return [0.25, 0.25, 0.25, 0.25];
|
||||||
|
var sums = [0,0,0,0,0,0];
|
||||||
|
var active = 0;
|
||||||
|
for (var i = 0; i < timings.length; i++) {
|
||||||
|
var t = timings[i];
|
||||||
|
if (t.did_work) active++;
|
||||||
|
for (var p = 0; p < 6 && p < t.phase_us.length; p++) sums[p] += t.phase_us[p];
|
||||||
|
}
|
||||||
|
var total = sums.reduce(function(a,b) { return a+b; }, 0);
|
||||||
|
if (total === 0) return [0.25, 0.25, 0.25, 0.25];
|
||||||
|
var processing = sums[2] / total;
|
||||||
|
var delivery = (sums[1] + sums[4]) / total;
|
||||||
|
var spawns = (sums[0] + sums[3]) / total;
|
||||||
|
var overhead = sums[5] / total;
|
||||||
|
var load = timings.length > 0 ? active / timings.length : 0;
|
||||||
|
return { fracs: [processing, delivery, spawns, overhead], load: load };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWorkerCards(data) {
|
||||||
|
var container = document.getElementById('workerCards');
|
||||||
|
if (!data.workers) return;
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
data.workers.forEach(function(w, idx) {
|
||||||
|
var timings = data.tick_timings ? data.tick_timings[idx] : null;
|
||||||
|
var phases = computePhases(timings);
|
||||||
|
var load = phases.load || 0;
|
||||||
|
var fracs = phases.fracs || [0.25, 0.25, 0.25, 0.25];
|
||||||
|
|
||||||
|
var card = document.createElement('div');
|
||||||
|
card.className = 'worker-card';
|
||||||
|
|
||||||
|
// ID
|
||||||
|
var idSpan = document.createElement('span');
|
||||||
|
idSpan.className = 'wc-id';
|
||||||
|
idSpan.style.color = colors[w.id % colors.length];
|
||||||
|
idSpan.textContent = 'W' + w.id;
|
||||||
|
card.appendChild(idSpan);
|
||||||
|
|
||||||
|
// Phase bar
|
||||||
|
var barWrap = document.createElement('span');
|
||||||
|
barWrap.className = 'wc-bar-wrap';
|
||||||
|
var filledPct = Math.round(load * 100);
|
||||||
|
for (var p = 0; p < 4; p++) {
|
||||||
|
var seg = document.createElement('span');
|
||||||
|
seg.className = 'wc-bar-seg';
|
||||||
|
seg.style.width = (fracs[p] * filledPct) + '%';
|
||||||
|
seg.style.background = phaseColors[p];
|
||||||
|
barWrap.appendChild(seg);
|
||||||
|
}
|
||||||
|
card.appendChild(barWrap);
|
||||||
|
|
||||||
|
// Sparklines
|
||||||
|
var sparkWrap = document.createElement('span');
|
||||||
|
sparkWrap.className = 'wc-spark';
|
||||||
|
var wh = workerHistory[w.id];
|
||||||
|
if (wh) {
|
||||||
|
sparkWrap.innerHTML = renderSparklineSvg(wh.message_rates, 60, 14, '#4caf50');
|
||||||
|
}
|
||||||
|
card.appendChild(sparkWrap);
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
var statsSpan = document.createElement('span');
|
||||||
|
statsSpan.className = 'wc-stats';
|
||||||
|
statsSpan.textContent = w.num_actors + ' actors ' +
|
||||||
|
w.messages_processed.toLocaleString() + ' msgs mbox ' + w.mailbox_depth +
|
||||||
|
' ' + Math.round(load * 100) + '%';
|
||||||
|
card.appendChild(statsSpan);
|
||||||
|
|
||||||
|
container.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
renderWorkerCards(data);
|
||||||
|
|
||||||
|
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;"><a href="/actor/' + hex + '" style="color:#aaa;text-decoration:none;">' + hex + '</a></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 renderSparklineSvg(data, w, h, color) {
|
||||||
|
if (!data || data.length < 2) return '';
|
||||||
|
var max = Math.max.apply(null, data);
|
||||||
|
if (max === 0) max = 1;
|
||||||
|
var step = w / (data.length - 1);
|
||||||
|
var points = data.map(function(v, i) {
|
||||||
|
return (i * step).toFixed(1) + ',' + (h - (v / max) * (h - 2) - 1).toFixed(1);
|
||||||
|
}).join(' ');
|
||||||
|
return '<svg width="' + w + '" height="' + h + '" style="vertical-align:middle">' +
|
||||||
|
'<polyline fill="none" stroke="' + color + '" stroke-width="1.5" points="' + points + '"/></svg>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushHistorySample(stats) {
|
||||||
|
if (!stats.workers) return;
|
||||||
|
stats.workers.forEach(function(w) {
|
||||||
|
if (!workerHistory[w.id]) {
|
||||||
|
workerHistory[w.id] = { message_rates: [], mailbox_depths: [], prev_msgs: w.messages_processed };
|
||||||
|
}
|
||||||
|
var wh = workerHistory[w.id];
|
||||||
|
var rate = w.messages_processed - wh.prev_msgs;
|
||||||
|
if (rate < 0) rate = 0;
|
||||||
|
wh.prev_msgs = w.messages_processed;
|
||||||
|
wh.message_rates.push(rate);
|
||||||
|
wh.mailbox_depths.push(w.mailbox_depth);
|
||||||
|
if (wh.message_rates.length > 300) { wh.message_rates.shift(); wh.mailbox_depths.shift(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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>' : '';
|
||||||
|
var wh = workerHistory[wid];
|
||||||
|
var sparkHtml = '';
|
||||||
|
if (wh) {
|
||||||
|
sparkHtml = '<span class="sparkline-wrap">' +
|
||||||
|
renderSparklineSvg(wh.message_rates, 80, 16, '#4caf50') +
|
||||||
|
renderSparklineSvg(wh.mailbox_depths, 80, 16, '#2196f3') +
|
||||||
|
'</span>';
|
||||||
|
}
|
||||||
|
hdr.innerHTML =
|
||||||
|
'<span class="wid" style="color:' + colors[wid % colors.length] + '">W' + wid + '</span>' +
|
||||||
|
sparkHtml +
|
||||||
|
'<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;"><a href="/actor/' + hex + '" style="color:#aaa;text-decoration:none;">' + hex + '</a></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 {
|
||||||
|
var data = JSON.parse(e.data);
|
||||||
|
pushHistorySample(data);
|
||||||
|
updateStats(data);
|
||||||
|
} catch(err) { console.error('stats parse error', err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener('history', function(e) {
|
||||||
|
try {
|
||||||
|
var data = JSON.parse(e.data);
|
||||||
|
if (data.workers) {
|
||||||
|
data.workers.forEach(function(w) {
|
||||||
|
workerHistory[w.id] = {
|
||||||
|
message_rates: w.message_rates || [],
|
||||||
|
mailbox_depths: w.mailbox_depths || [],
|
||||||
|
prev_msgs: 0
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch(err) { console.error('history parse error', err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener('warnings', function(e) {
|
||||||
|
try {
|
||||||
|
var warnings = JSON.parse(e.data);
|
||||||
|
var banner = document.getElementById('warningBanner');
|
||||||
|
if (warnings.length === 0) {
|
||||||
|
banner.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
banner.style.display = 'block';
|
||||||
|
var sevColors = {critical:'#f44336',high:'#ff5722',medium:'#ff9800',low:'#888'};
|
||||||
|
var html = warnings.map(function(w) {
|
||||||
|
var c = sevColors[w.severity] || '#888';
|
||||||
|
return '<span style="color:' + c + ';">\u26A0 ' + w.description + '</span>';
|
||||||
|
}).join(' ');
|
||||||
|
banner.innerHTML = '<span style="color:#ff9800;font-weight:700;">WARNINGS (' + warnings.length + ')</span> ' + html;
|
||||||
|
} catch(err) { console.error('warnings 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>
|
pub const DASHBOARD_HTML: &str = include_str\!("dashboard.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>
|
|
||||||
"##;
|
|
||||||
|
|
|
||||||
767
crates/runtime-dashboard/src/distribution.html
Normal file
767
crates/runtime-dashboard/src/distribution.html
Normal file
|
|
@ -0,0 +1,767 @@
|
||||||
|
<!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; overflow: hidden;
|
||||||
|
}
|
||||||
|
.graph-panel canvas { position: absolute; top: 0; left: 0; 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 <span id="probeCount" 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;">State</th>
|
||||||
|
<th style="color:#888;font-weight:500;font-size:11px;">Node</th>
|
||||||
|
<th style="color:#888;font-weight:500;font-size:11px;">Address</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody id="probesBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</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 — cross-reference with members for state + address
|
||||||
|
var memberMap = {};
|
||||||
|
for (var i = 0; i < d.members.length; i++) {
|
||||||
|
memberMap[d.members[i].node_id] = d.members[i];
|
||||||
|
}
|
||||||
|
var probesBody = document.getElementById('probesBody');
|
||||||
|
probesBody.innerHTML = '';
|
||||||
|
document.getElementById('probeCount').textContent = '(' + d.recent_probe_targets.length + ')';
|
||||||
|
for (var i = d.recent_probe_targets.length - 1; i >= 0; i--) {
|
||||||
|
var pid = d.recent_probe_targets[i];
|
||||||
|
var mem = memberMap[pid];
|
||||||
|
var state = mem ? mem.state : 'unknown';
|
||||||
|
var addr = mem ? mem.addr : '\u2014';
|
||||||
|
var cls = 'state-' + state;
|
||||||
|
var tr = document.createElement('tr');
|
||||||
|
tr.innerHTML =
|
||||||
|
'<td class="' + cls + '" style="font-size:10px;">' + state + '</td>' +
|
||||||
|
'<td style="color:#aaa;font-size:10px;">' + pid.substring(0, 12) + '\u2026</td>' +
|
||||||
|
'<td style="font-size:10px;">' + addr + '</td>';
|
||||||
|
probesBody.appendChild(tr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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>
|
pub const DISTRIBUTION_HTML: &str = include_str\!("distribution.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>
|
|
||||||
"##;
|
|
||||||
|
|
|
||||||
344
crates/runtime-dashboard/src/history.rs
Normal file
344
crates/runtime-dashboard/src/history.rs
Normal file
|
|
@ -0,0 +1,344 @@
|
||||||
|
//! In-process time-series history for dashboard sparklines and trend detection.
|
||||||
|
//!
|
||||||
|
//! Stores bounded ring buffers of per-worker and per-actor stats, sampled at
|
||||||
|
//! a configurable interval. All data is kept in memory with automatic eviction
|
||||||
|
//! of the oldest samples when capacity is reached.
|
||||||
|
|
||||||
|
use std::collections::{HashMap, VecDeque};
|
||||||
|
use std::sync::RwLock;
|
||||||
|
|
||||||
|
use swactor::actor::ActorAddress;
|
||||||
|
use swactor::stats::{ActorInfo, RuntimeStats};
|
||||||
|
|
||||||
|
/// Configuration for history collection.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct HistoryConfig {
|
||||||
|
/// Maximum samples per worker (default: 300 = 5 min at 1/sec).
|
||||||
|
pub max_worker_samples: usize,
|
||||||
|
/// Maximum samples per actor (default: 300).
|
||||||
|
pub max_actor_samples: usize,
|
||||||
|
/// Maximum number of actors tracked (LRU eviction). Default: 1000.
|
||||||
|
pub max_tracked_actors: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for HistoryConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
max_worker_samples: 300,
|
||||||
|
max_actor_samples: 300,
|
||||||
|
max_tracked_actors: 1000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Time-series data for a single worker.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct WorkerHistory {
|
||||||
|
pub message_rates: VecDeque<f64>,
|
||||||
|
pub mailbox_depths: VecDeque<u64>,
|
||||||
|
pub actor_counts: VecDeque<u32>,
|
||||||
|
prev_messages: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkerHistory {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
message_rates: VecDeque::new(),
|
||||||
|
mailbox_depths: VecDeque::new(),
|
||||||
|
actor_counts: VecDeque::new(),
|
||||||
|
prev_messages: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push(&mut self, messages_processed: u64, mailbox_depth: usize, num_actors: usize, cap: usize) {
|
||||||
|
let rate = messages_processed.saturating_sub(self.prev_messages) as f64;
|
||||||
|
self.prev_messages = messages_processed;
|
||||||
|
|
||||||
|
push_bounded(&mut self.message_rates, rate, cap);
|
||||||
|
push_bounded(&mut self.mailbox_depths, mailbox_depth as u64, cap);
|
||||||
|
push_bounded(&mut self.actor_counts, num_actors as u32, cap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Time-series data for a single actor.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ActorHistory {
|
||||||
|
pub mailbox_depths: VecDeque<u64>,
|
||||||
|
pub message_rates: VecDeque<f64>,
|
||||||
|
prev_messages: u64,
|
||||||
|
last_seen_sample: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActorHistory {
|
||||||
|
fn new(sample_counter: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
mailbox_depths: VecDeque::new(),
|
||||||
|
message_rates: VecDeque::new(),
|
||||||
|
prev_messages: 0,
|
||||||
|
last_seen_sample: sample_counter,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push(&mut self, info: &ActorInfo, cap: usize, sample_counter: u64) {
|
||||||
|
let rate = info.messages_processed.saturating_sub(self.prev_messages) as f64;
|
||||||
|
self.prev_messages = info.messages_processed;
|
||||||
|
self.last_seen_sample = sample_counter;
|
||||||
|
|
||||||
|
push_bounded(&mut self.mailbox_depths, info.mailbox_depth as u64, cap);
|
||||||
|
push_bounded(&mut self.message_rates, rate, cap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_bounded<T>(buf: &mut VecDeque<T>, val: T, cap: usize) {
|
||||||
|
if buf.len() >= cap {
|
||||||
|
buf.pop_front();
|
||||||
|
}
|
||||||
|
buf.push_back(val);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Thread-safe history store. Written by the sampler, read by SSE/TUI.
|
||||||
|
pub struct DashboardHistory {
|
||||||
|
inner: RwLock<HistoryInner>,
|
||||||
|
config: HistoryConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HistoryInner {
|
||||||
|
workers: Vec<WorkerHistory>,
|
||||||
|
actors: HashMap<ActorAddress, ActorHistory>,
|
||||||
|
sample_counter: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DashboardHistory {
|
||||||
|
pub fn new(config: HistoryConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
inner: RwLock::new(HistoryInner {
|
||||||
|
workers: Vec::new(),
|
||||||
|
actors: HashMap::new(),
|
||||||
|
sample_counter: 0,
|
||||||
|
}),
|
||||||
|
config,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a stats snapshot. Called by the sampler thread.
|
||||||
|
pub fn record(&self, stats: &RuntimeStats) {
|
||||||
|
let mut inner = self.inner.write().unwrap();
|
||||||
|
inner.sample_counter += 1;
|
||||||
|
let counter = inner.sample_counter;
|
||||||
|
|
||||||
|
// Resize workers vec if needed
|
||||||
|
while inner.workers.len() < stats.workers.len() {
|
||||||
|
inner.workers.push(WorkerHistory::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record per-worker data
|
||||||
|
for w in &stats.workers {
|
||||||
|
if let Some(wh) = inner.workers.get_mut(w.id) {
|
||||||
|
wh.push(
|
||||||
|
w.messages_processed,
|
||||||
|
w.mailbox_depth,
|
||||||
|
w.num_actors,
|
||||||
|
self.config.max_worker_samples,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record per-actor data
|
||||||
|
for a in &stats.actor_details {
|
||||||
|
let ah = inner.actors.entry(a.address).or_insert_with(|| ActorHistory::new(counter));
|
||||||
|
ah.push(a, self.config.max_actor_samples, counter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// LRU eviction: remove actors not seen recently if over capacity
|
||||||
|
if inner.actors.len() > self.config.max_tracked_actors {
|
||||||
|
let mut entries: Vec<(ActorAddress, u64)> = inner
|
||||||
|
.actors
|
||||||
|
.iter()
|
||||||
|
.map(|(addr, ah)| (*addr, ah.last_seen_sample))
|
||||||
|
.collect();
|
||||||
|
entries.sort_by_key(|&(_, seen)| seen);
|
||||||
|
let to_remove = inner.actors.len() - self.config.max_tracked_actors;
|
||||||
|
for (addr, _) in entries.into_iter().take(to_remove) {
|
||||||
|
inner.actors.remove(&addr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a snapshot of worker history for rendering sparklines.
|
||||||
|
/// Returns Vec indexed by worker_id, each containing recent message rates.
|
||||||
|
pub fn worker_sparklines(&self) -> Vec<Vec<u64>> {
|
||||||
|
let inner = self.inner.read().unwrap();
|
||||||
|
inner
|
||||||
|
.workers
|
||||||
|
.iter()
|
||||||
|
.map(|wh| wh.message_rates.iter().map(|r| *r as u64).collect())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get worker mailbox depth history.
|
||||||
|
pub fn worker_mailbox_sparklines(&self) -> Vec<Vec<u64>> {
|
||||||
|
let inner = self.inner.read().unwrap();
|
||||||
|
inner
|
||||||
|
.workers
|
||||||
|
.iter()
|
||||||
|
.map(|wh| wh.mailbox_depths.iter().copied().collect())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get sparkline data for a specific actor.
|
||||||
|
pub fn actor_sparkline(&self, addr: &ActorAddress) -> Option<(Vec<u64>, Vec<u64>)> {
|
||||||
|
let inner = self.inner.read().unwrap();
|
||||||
|
inner.actors.get(addr).map(|ah| {
|
||||||
|
let mailbox: Vec<u64> = ah.mailbox_depths.iter().copied().collect();
|
||||||
|
let rates: Vec<u64> = ah.message_rates.iter().map(|r| *r as u64).collect();
|
||||||
|
(mailbox, rates)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get total sample count (useful for knowing if history is available).
|
||||||
|
pub fn sample_count(&self) -> u64 {
|
||||||
|
self.inner.read().unwrap().sample_counter
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize worker history as JSON for the SSE initial payload.
|
||||||
|
pub fn worker_history_json(&self) -> String {
|
||||||
|
let sparklines = self.worker_sparklines();
|
||||||
|
let mailbox = self.worker_mailbox_sparklines();
|
||||||
|
serde_json::json!({
|
||||||
|
"workers": sparklines.iter().enumerate().map(|(i, rates)| {
|
||||||
|
serde_json::json!({
|
||||||
|
"id": i,
|
||||||
|
"message_rates": rates,
|
||||||
|
"mailbox_depths": mailbox.get(i).unwrap_or(&Vec::new()),
|
||||||
|
})
|
||||||
|
}).collect::<Vec<_>>(),
|
||||||
|
})
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use swactor::stats::{ActorInfo, WorkerInfo};
|
||||||
|
|
||||||
|
fn make_stats(workers: Vec<(u64, usize, usize)>, actors: Vec<ActorInfo>) -> RuntimeStats {
|
||||||
|
RuntimeStats {
|
||||||
|
num_workers: workers.len(),
|
||||||
|
uptime_ms: 0,
|
||||||
|
actors: actors.iter().map(|a| (a.address, a.worker_id)).collect(),
|
||||||
|
workers: workers
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(id, (msgs, depth, n_actors))| WorkerInfo {
|
||||||
|
id,
|
||||||
|
num_actors: n_actors,
|
||||||
|
mailbox_depth: depth,
|
||||||
|
messages_processed: msgs,
|
||||||
|
local_sends: 0,
|
||||||
|
cross_sends: 0,
|
||||||
|
inbox_sends: 0,
|
||||||
|
type_mismatches: 0,
|
||||||
|
panics: 0,
|
||||||
|
messages_dropped: 0,
|
||||||
|
restarts: 0,
|
||||||
|
stops: 0,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
actor_details: actors,
|
||||||
|
tick_timings: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_actor(id: u8, worker: usize, depth: usize, msgs: u64) -> ActorInfo {
|
||||||
|
ActorInfo {
|
||||||
|
address: ActorAddress([id; 32]),
|
||||||
|
worker_id: worker,
|
||||||
|
mailbox_depth: depth,
|
||||||
|
last_msg_type: None,
|
||||||
|
messages_processed: msgs,
|
||||||
|
poisoned: false,
|
||||||
|
message_type_counts: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_rates_accumulate_over_samples() {
|
||||||
|
let history = DashboardHistory::new(HistoryConfig::default());
|
||||||
|
|
||||||
|
// First sample: establishes baseline (rate will be the raw value since prev=0)
|
||||||
|
let stats1 = make_stats(vec![(100, 5, 2)], vec![]);
|
||||||
|
history.record(&stats1);
|
||||||
|
|
||||||
|
// Second sample: delta = 150 - 100 = 50
|
||||||
|
let stats2 = make_stats(vec![(150, 3, 2)], vec![]);
|
||||||
|
history.record(&stats2);
|
||||||
|
|
||||||
|
let sparklines = history.worker_sparklines();
|
||||||
|
assert_eq!(sparklines.len(), 1);
|
||||||
|
assert_eq!(sparklines[0].len(), 2);
|
||||||
|
assert_eq!(sparklines[0][0], 100); // first sample: 100 - 0
|
||||||
|
assert_eq!(sparklines[0][1], 50); // second sample: 150 - 100
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bounded_eviction_drops_oldest() {
|
||||||
|
let config = HistoryConfig {
|
||||||
|
max_worker_samples: 3,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let history = DashboardHistory::new(config);
|
||||||
|
|
||||||
|
for i in 0..5u64 {
|
||||||
|
let stats = make_stats(vec![(i * 10, 0, 0)], vec![]);
|
||||||
|
history.record(&stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
let sparklines = history.worker_sparklines();
|
||||||
|
assert_eq!(sparklines[0].len(), 3); // capped at 3
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn actor_lru_eviction_keeps_most_recent() {
|
||||||
|
let config = HistoryConfig {
|
||||||
|
max_tracked_actors: 2,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let history = DashboardHistory::new(config);
|
||||||
|
|
||||||
|
// Sample 1: actors A and B
|
||||||
|
let stats1 = make_stats(
|
||||||
|
vec![(0, 0, 2)],
|
||||||
|
vec![make_actor(1, 0, 0, 0), make_actor(2, 0, 0, 0)],
|
||||||
|
);
|
||||||
|
history.record(&stats1);
|
||||||
|
|
||||||
|
// Sample 2: actors B and C (A not seen)
|
||||||
|
let stats2 = make_stats(
|
||||||
|
vec![(0, 0, 2)],
|
||||||
|
vec![make_actor(2, 0, 0, 0), make_actor(3, 0, 0, 0)],
|
||||||
|
);
|
||||||
|
history.record(&stats2);
|
||||||
|
|
||||||
|
// A should be evicted (LRU), B and C kept
|
||||||
|
assert!(history.actor_sparkline(&ActorAddress([1; 32])).is_none());
|
||||||
|
assert!(history.actor_sparkline(&ActorAddress([2; 32])).is_some());
|
||||||
|
assert!(history.actor_sparkline(&ActorAddress([3; 32])).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn actor_rates_track_deltas() {
|
||||||
|
let history = DashboardHistory::new(HistoryConfig::default());
|
||||||
|
|
||||||
|
let stats1 = make_stats(vec![(0, 0, 1)], vec![make_actor(1, 0, 5, 100)]);
|
||||||
|
history.record(&stats1);
|
||||||
|
|
||||||
|
let stats2 = make_stats(vec![(0, 0, 1)], vec![make_actor(1, 0, 3, 175)]);
|
||||||
|
history.record(&stats2);
|
||||||
|
|
||||||
|
let (mailbox, rates) = history.actor_sparkline(&ActorAddress([1; 32])).unwrap();
|
||||||
|
assert_eq!(mailbox, vec![5, 3]);
|
||||||
|
assert_eq!(rates[0], 100); // first: 100 - 0
|
||||||
|
assert_eq!(rates[1], 75); // second: 175 - 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,7 @@ pub struct DashboardEvent {
|
||||||
pub level: String,
|
pub level: String,
|
||||||
pub message: String,
|
pub message: String,
|
||||||
pub worker_id: Option<usize>,
|
pub worker_id: Option<usize>,
|
||||||
|
pub actor_addr: Option<String>,
|
||||||
pub fields: serde_json::Map<String, serde_json::Value>,
|
pub fields: serde_json::Map<String, serde_json::Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -83,6 +84,28 @@ impl EventStore {
|
||||||
(batch, new_cursor)
|
(batch, new_cursor)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read recent events for a specific actor address (hex prefix match).
|
||||||
|
/// Returns up to `limit` most recent matching events.
|
||||||
|
pub fn read_for_actor(&self, actor_hex: &str, limit: usize) -> Vec<DashboardEvent> {
|
||||||
|
let events = self.events.lock().unwrap();
|
||||||
|
let lower = actor_hex.to_lowercase();
|
||||||
|
events
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.filter(|e| {
|
||||||
|
e.actor_addr
|
||||||
|
.as_ref()
|
||||||
|
.map(|a| a.to_lowercase().starts_with(&lower) || a.to_lowercase().contains(&lower))
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.take(limit)
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.rev()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Drains the full recording log. Only available when recording is enabled.
|
/// Drains the full recording log. Only available when recording is enabled.
|
||||||
/// This is destructive — events are consumed. Intended for `save_trace()`.
|
/// This is destructive — events are consumed. Intended for `save_trace()`.
|
||||||
pub fn all_events(&self) -> Option<Vec<DashboardEvent>> {
|
pub fn all_events(&self) -> Option<Vec<DashboardEvent>> {
|
||||||
|
|
@ -183,24 +206,39 @@ where
|
||||||
let mut visitor = FieldVisitor::new();
|
let mut visitor = FieldVisitor::new();
|
||||||
event.record(&mut visitor);
|
event.record(&mut visitor);
|
||||||
|
|
||||||
// Walk span context to find worker_id
|
// Walk span context to find worker_id and actor_addr
|
||||||
let mut worker_id = None;
|
let mut worker_id = None;
|
||||||
|
let mut actor_addr = None;
|
||||||
if let Some(scope) = ctx.event_scope(event) {
|
if let Some(scope) = ctx.event_scope(event) {
|
||||||
for span in scope {
|
for span in scope {
|
||||||
let exts = span.extensions();
|
let exts = span.extensions();
|
||||||
|
if worker_id.is_none() {
|
||||||
if let Some(wid) = exts.get::<WorkerIdField>() {
|
if let Some(wid) = exts.get::<WorkerIdField>() {
|
||||||
worker_id = Some(wid.0);
|
worker_id = Some(wid.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if actor_addr.is_none() {
|
||||||
|
if let Some(aa) = exts.get::<ActorAddrField>() {
|
||||||
|
actor_addr = Some(aa.0.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if worker_id.is_some() && actor_addr.is_some() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also check if worker_id was a field on the event itself
|
// Also check if worker_id or actor_addr was a field on the event itself
|
||||||
if worker_id.is_none() {
|
if worker_id.is_none() {
|
||||||
if let Some(serde_json::Value::Number(n)) = visitor.fields.get("worker_id") {
|
if let Some(serde_json::Value::Number(n)) = visitor.fields.get("worker_id") {
|
||||||
worker_id = n.as_u64().map(|v| v as usize);
|
worker_id = n.as_u64().map(|v| v as usize);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if actor_addr.is_none() {
|
||||||
|
if let Some(serde_json::Value::String(s)) = visitor.fields.get("actor_addr") {
|
||||||
|
actor_addr = Some(s.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let dashboard_event = DashboardEvent {
|
let dashboard_event = DashboardEvent {
|
||||||
seq: 0, // filled by push()
|
seq: 0, // filled by push()
|
||||||
|
|
@ -208,6 +246,7 @@ where
|
||||||
level: event.metadata().level().to_string(),
|
level: event.metadata().level().to_string(),
|
||||||
message: visitor.message,
|
message: visitor.message,
|
||||||
worker_id,
|
worker_id,
|
||||||
|
actor_addr,
|
||||||
fields: visitor.fields,
|
fields: visitor.fields,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -215,19 +254,25 @@ where
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_new_span(&self, attrs: &span::Attributes<'_>, id: &span::Id, ctx: Context<'_, S>) {
|
fn on_new_span(&self, attrs: &span::Attributes<'_>, id: &span::Id, ctx: Context<'_, S>) {
|
||||||
// Extract worker_id from span fields and store in extensions
|
// Extract worker_id and actor_addr from span fields and store in extensions
|
||||||
let mut visitor = FieldVisitor::new();
|
let mut visitor = FieldVisitor::new();
|
||||||
attrs.record(&mut visitor);
|
attrs.record(&mut visitor);
|
||||||
|
|
||||||
|
if let Some(span) = ctx.span(id) {
|
||||||
if let Some(serde_json::Value::Number(n)) = visitor.fields.get("worker_id") {
|
if let Some(serde_json::Value::Number(n)) = visitor.fields.get("worker_id") {
|
||||||
if let Some(wid) = n.as_u64() {
|
if let Some(wid) = n.as_u64() {
|
||||||
if let Some(span) = ctx.span(id) {
|
|
||||||
span.extensions_mut().insert(WorkerIdField(wid as usize));
|
span.extensions_mut().insert(WorkerIdField(wid as usize));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(serde_json::Value::String(s)) = visitor.fields.get("actor_addr") {
|
||||||
|
span.extensions_mut().insert(ActorAddrField(s.clone()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stored in span extensions to propagate worker_id to child events.
|
/// Stored in span extensions to propagate worker_id to child events.
|
||||||
struct WorkerIdField(usize);
|
struct WorkerIdField(usize);
|
||||||
|
|
||||||
|
/// Stored in span extensions to propagate actor_addr to child events.
|
||||||
|
struct ActorAddrField(String);
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,15 @@
|
||||||
pub mod collector;
|
pub mod collector;
|
||||||
|
pub mod history;
|
||||||
pub mod investigate;
|
pub mod investigate;
|
||||||
pub mod layer;
|
pub mod layer;
|
||||||
pub mod trace;
|
pub mod trace;
|
||||||
|
pub mod warnings;
|
||||||
|
mod actor_detail_html;
|
||||||
mod actors_html;
|
mod actors_html;
|
||||||
mod dashboard_html;
|
mod dashboard_html;
|
||||||
mod server;
|
mod server;
|
||||||
|
pub mod topology;
|
||||||
|
mod topology_html;
|
||||||
|
|
||||||
#[cfg(feature = "tui")]
|
#[cfg(feature = "tui")]
|
||||||
pub mod tui;
|
pub mod tui;
|
||||||
|
|
@ -27,6 +32,7 @@ use tracing_subscriber::layer::SubscriberExt;
|
||||||
use tracing_subscriber::util::SubscriberInitExt;
|
use tracing_subscriber::util::SubscriberInitExt;
|
||||||
|
|
||||||
use crate::collector::StatsCollector;
|
use crate::collector::StatsCollector;
|
||||||
|
use crate::history::{DashboardHistory, HistoryConfig};
|
||||||
use crate::layer::{now_ms, DashboardLayer, EventStore};
|
use crate::layer::{now_ms, DashboardLayer, EventStore};
|
||||||
use crate::trace::{RuntimeTrace, TimestampedStats};
|
use crate::trace::{RuntimeTrace, TimestampedStats};
|
||||||
|
|
||||||
|
|
@ -80,6 +86,7 @@ pub struct DashboardHandle {
|
||||||
collector: Arc<Mutex<Option<Arc<StatsCollector>>>>,
|
collector: Arc<Mutex<Option<Arc<StatsCollector>>>>,
|
||||||
shutdown: Arc<AtomicBool>,
|
shutdown: Arc<AtomicBool>,
|
||||||
stats_timeline: Arc<ArrayQueue<TimestampedStats>>,
|
stats_timeline: Arc<ArrayQueue<TimestampedStats>>,
|
||||||
|
history: Arc<DashboardHistory>,
|
||||||
recording: bool,
|
recording: bool,
|
||||||
#[cfg(feature = "distribution")]
|
#[cfg(feature = "distribution")]
|
||||||
distribution: Arc<Mutex<Option<Arc<dyn distribution_collector::DistributionStatsProvider>>>>,
|
distribution: Arc<Mutex<Option<Arc<dyn distribution_collector::DistributionStatsProvider>>>>,
|
||||||
|
|
@ -115,6 +122,11 @@ impl DashboardHandle {
|
||||||
*self.distribution.lock().unwrap() = Some(provider);
|
*self.distribution.lock().unwrap() = Some(provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Access the time-series history store (for TUI sparklines, etc.).
|
||||||
|
pub fn history(&self) -> &Arc<DashboardHistory> {
|
||||||
|
&self.history
|
||||||
|
}
|
||||||
|
|
||||||
/// Signal the dashboard to shut down (SSE clients receive "done").
|
/// Signal the dashboard to shut down (SSE clients receive "done").
|
||||||
pub fn shutdown(&self) {
|
pub fn shutdown(&self) {
|
||||||
self.shutdown.store(true, Ordering::Release);
|
self.shutdown.store(true, Ordering::Release);
|
||||||
|
|
@ -159,6 +171,7 @@ pub fn start_dashboard(config: DashboardConfig) -> DashboardHandle {
|
||||||
let collector: Arc<Mutex<Option<Arc<StatsCollector>>>> = Arc::new(Mutex::new(None));
|
let collector: Arc<Mutex<Option<Arc<StatsCollector>>>> = Arc::new(Mutex::new(None));
|
||||||
let shutdown = Arc::new(AtomicBool::new(false));
|
let shutdown = Arc::new(AtomicBool::new(false));
|
||||||
let stats_timeline = Arc::new(ArrayQueue::new(config.record_stats_capacity.max(1)));
|
let stats_timeline = Arc::new(ArrayQueue::new(config.record_stats_capacity.max(1)));
|
||||||
|
let history = Arc::new(DashboardHistory::new(HistoryConfig::default()));
|
||||||
|
|
||||||
#[cfg(feature = "distribution")]
|
#[cfg(feature = "distribution")]
|
||||||
let distribution: Arc<Mutex<Option<Arc<dyn distribution_collector::DistributionStatsProvider>>>> =
|
let distribution: Arc<Mutex<Option<Arc<dyn distribution_collector::DistributionStatsProvider>>>> =
|
||||||
|
|
@ -169,6 +182,7 @@ pub fn start_dashboard(config: DashboardConfig) -> DashboardHandle {
|
||||||
Arc::clone(&runtime),
|
Arc::clone(&runtime),
|
||||||
Arc::clone(&collector),
|
Arc::clone(&collector),
|
||||||
Arc::clone(&shutdown),
|
Arc::clone(&shutdown),
|
||||||
|
Arc::clone(&history),
|
||||||
config.port,
|
config.port,
|
||||||
#[cfg(feature = "distribution")]
|
#[cfg(feature = "distribution")]
|
||||||
Arc::clone(&distribution),
|
Arc::clone(&distribution),
|
||||||
|
|
@ -210,6 +224,7 @@ pub fn start_dashboard(config: DashboardConfig) -> DashboardHandle {
|
||||||
collector,
|
collector,
|
||||||
shutdown,
|
shutdown,
|
||||||
stats_timeline,
|
stats_timeline,
|
||||||
|
history,
|
||||||
recording: config.record,
|
recording: config.record,
|
||||||
#[cfg(feature = "distribution")]
|
#[cfg(feature = "distribution")]
|
||||||
distribution,
|
distribution,
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,16 @@ use std::collections::HashMap;
|
||||||
|
|
||||||
use swactor::runtime::Runtime;
|
use swactor::runtime::Runtime;
|
||||||
|
|
||||||
|
use crate::actor_detail_html::ACTOR_DETAIL_HTML;
|
||||||
use crate::actors_html::ACTORS_HTML;
|
use crate::actors_html::ACTORS_HTML;
|
||||||
use crate::collector::StatsCollector;
|
use crate::collector::StatsCollector;
|
||||||
use crate::dashboard_html::DASHBOARD_HTML;
|
use crate::dashboard_html::DASHBOARD_HTML;
|
||||||
|
use crate::history::DashboardHistory;
|
||||||
use crate::layer::EventStore;
|
use crate::layer::EventStore;
|
||||||
|
use crate::topology;
|
||||||
|
use crate::topology_html::TOPOLOGY_HTML;
|
||||||
use crate::trace::RuntimeTrace;
|
use crate::trace::RuntimeTrace;
|
||||||
|
use crate::warnings::{WarningConfig, WarningDetector};
|
||||||
|
|
||||||
#[cfg(feature = "distribution")]
|
#[cfg(feature = "distribution")]
|
||||||
use crate::distribution_collector::DistributionStatsProvider;
|
use crate::distribution_collector::DistributionStatsProvider;
|
||||||
|
|
@ -119,6 +124,7 @@ pub(crate) fn spawn_http_server(
|
||||||
runtime: Arc<Mutex<Option<Arc<Runtime>>>>,
|
runtime: Arc<Mutex<Option<Arc<Runtime>>>>,
|
||||||
collector: Arc<Mutex<Option<Arc<StatsCollector>>>>,
|
collector: Arc<Mutex<Option<Arc<StatsCollector>>>>,
|
||||||
shutdown: Arc<AtomicBool>,
|
shutdown: Arc<AtomicBool>,
|
||||||
|
history: Arc<DashboardHistory>,
|
||||||
port: u16,
|
port: u16,
|
||||||
#[cfg(feature = "distribution")]
|
#[cfg(feature = "distribution")]
|
||||||
distribution: Arc<Mutex<Option<Arc<dyn DistributionStatsProvider>>>>,
|
distribution: Arc<Mutex<Option<Arc<dyn DistributionStatsProvider>>>>,
|
||||||
|
|
@ -134,6 +140,7 @@ pub(crate) fn spawn_http_server(
|
||||||
let runtime = Arc::clone(&runtime);
|
let runtime = Arc::clone(&runtime);
|
||||||
let collector = Arc::clone(&collector);
|
let collector = Arc::clone(&collector);
|
||||||
let shutdown = Arc::clone(&shutdown);
|
let shutdown = Arc::clone(&shutdown);
|
||||||
|
let history = Arc::clone(&history);
|
||||||
let cmd_router = Arc::clone(&cmd_router);
|
let cmd_router = Arc::clone(&cmd_router);
|
||||||
#[cfg(feature = "distribution")]
|
#[cfg(feature = "distribution")]
|
||||||
let distribution = Arc::clone(&distribution);
|
let distribution = Arc::clone(&distribution);
|
||||||
|
|
@ -149,6 +156,7 @@ pub(crate) fn spawn_http_server(
|
||||||
match path {
|
match path {
|
||||||
"/" => respond_html(request, DASHBOARD_HTML, "live"),
|
"/" => respond_html(request, DASHBOARD_HTML, "live"),
|
||||||
"/actors" => respond_html(request, ACTORS_HTML, "live"),
|
"/actors" => respond_html(request, ACTORS_HTML, "live"),
|
||||||
|
"/topology" => respond_html(request, TOPOLOGY_HTML, "live"),
|
||||||
#[cfg(feature = "distribution")]
|
#[cfg(feature = "distribution")]
|
||||||
"/distribution" => respond_html(request, DISTRIBUTION_HTML, "live"),
|
"/distribution" => respond_html(request, DISTRIBUTION_HTML, "live"),
|
||||||
"/events" => {
|
"/events" => {
|
||||||
|
|
@ -158,6 +166,7 @@ pub(crate) fn spawn_http_server(
|
||||||
Arc::clone(&runtime),
|
Arc::clone(&runtime),
|
||||||
Arc::clone(&collector),
|
Arc::clone(&collector),
|
||||||
Arc::clone(&shutdown),
|
Arc::clone(&shutdown),
|
||||||
|
Arc::clone(&history),
|
||||||
#[cfg(feature = "distribution")]
|
#[cfg(feature = "distribution")]
|
||||||
Arc::clone(&distribution),
|
Arc::clone(&distribution),
|
||||||
);
|
);
|
||||||
|
|
@ -169,6 +178,16 @@ pub(crate) fn spawn_http_server(
|
||||||
Arc::clone(&collector),
|
Arc::clone(&collector),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
"/api/history" => {
|
||||||
|
handle_history_api(request, Arc::clone(&history));
|
||||||
|
}
|
||||||
|
"/api/topology" => {
|
||||||
|
handle_topology_api(
|
||||||
|
request,
|
||||||
|
Arc::clone(&runtime),
|
||||||
|
Arc::clone(&collector),
|
||||||
|
);
|
||||||
|
}
|
||||||
"/api/investigate" => {
|
"/api/investigate" => {
|
||||||
handle_investigate_api(
|
handle_investigate_api(
|
||||||
request,
|
request,
|
||||||
|
|
@ -185,6 +204,13 @@ pub(crate) fn spawn_http_server(
|
||||||
Arc::clone(&distribution),
|
Arc::clone(&distribution),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
"/api/logs" => {
|
||||||
|
handle_logs_api(request, &url, Arc::clone(&store));
|
||||||
|
}
|
||||||
|
_ if path.starts_with("/actor/") => {
|
||||||
|
let hex = &path[7..]; // strip "/actor/"
|
||||||
|
respond_actor_detail(request, hex);
|
||||||
|
}
|
||||||
_ => respond_404(request),
|
_ => respond_404(request),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -192,12 +218,25 @@ pub(crate) fn spawn_http_server(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn respond_actor_detail(request: tiny_http::Request, hex_addr: &str) {
|
||||||
|
let html = ACTOR_DETAIL_HTML
|
||||||
|
.replace("__DASHBOARD_MODE__", "live")
|
||||||
|
.replace("__ACTOR_ADDR__", hex_addr);
|
||||||
|
let response = tiny_http::Response::from_string(html).with_header(
|
||||||
|
"Content-Type: text/html; charset=utf-8"
|
||||||
|
.parse::<tiny_http::Header>()
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
let _ = request.respond(response);
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_live_sse(
|
fn handle_live_sse(
|
||||||
request: tiny_http::Request,
|
request: tiny_http::Request,
|
||||||
store: Arc<EventStore>,
|
store: Arc<EventStore>,
|
||||||
runtime: Arc<Mutex<Option<Arc<Runtime>>>>,
|
runtime: Arc<Mutex<Option<Arc<Runtime>>>>,
|
||||||
collector: Arc<Mutex<Option<Arc<StatsCollector>>>>,
|
collector: Arc<Mutex<Option<Arc<StatsCollector>>>>,
|
||||||
shutdown: Arc<AtomicBool>,
|
shutdown: Arc<AtomicBool>,
|
||||||
|
history: Arc<DashboardHistory>,
|
||||||
#[cfg(feature = "distribution")]
|
#[cfg(feature = "distribution")]
|
||||||
distribution: Arc<Mutex<Option<Arc<dyn DistributionStatsProvider>>>>,
|
distribution: Arc<Mutex<Option<Arc<dyn DistributionStatsProvider>>>>,
|
||||||
) {
|
) {
|
||||||
|
|
@ -207,6 +246,14 @@ fn handle_live_sse(
|
||||||
// Spawn producer thread
|
// Spawn producer thread
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
let mut cursor: u64 = 0;
|
let mut cursor: u64 = 0;
|
||||||
|
let mut warning_detector = WarningDetector::new(WarningConfig::default());
|
||||||
|
let mut tick_count: u64 = 0;
|
||||||
|
|
||||||
|
// Send initial history snapshot so sparklines render immediately
|
||||||
|
if history.sample_count() > 0 {
|
||||||
|
let json = history.worker_history_json();
|
||||||
|
let _ = tx.send(format_sse("history", &json));
|
||||||
|
}
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
// Send stats if runtime is available
|
// Send stats if runtime is available
|
||||||
|
|
@ -217,10 +264,33 @@ fn handle_live_sse(
|
||||||
if let Some(col) = collector.lock().unwrap().as_ref() {
|
if let Some(col) = collector.lock().unwrap().as_ref() {
|
||||||
col.enrich(&mut stats);
|
col.enrich(&mut stats);
|
||||||
}
|
}
|
||||||
|
history.record(&stats);
|
||||||
|
|
||||||
|
// Run warning detection
|
||||||
|
let warnings = warning_detector.check(&stats);
|
||||||
|
if !warnings.is_empty() {
|
||||||
|
if let Ok(wjson) = serde_json::to_string(&warnings) {
|
||||||
|
if tx.send(format_sse("warnings", &wjson)).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let json = serde_json::to_string(&stats).unwrap();
|
let json = serde_json::to_string(&stats).unwrap();
|
||||||
if tx.send(format_sse("stats", &json)).is_err() {
|
if tx.send(format_sse("stats", &json)).is_err() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Send topology every 5th tick (~1/sec)
|
||||||
|
tick_count += 1;
|
||||||
|
if tick_count % 5 == 0 {
|
||||||
|
let topo = topology::worker_topology(&stats);
|
||||||
|
if let Ok(tjson) = serde_json::to_string(&topo) {
|
||||||
|
if tx.send(format_sse("topology", &tjson)).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -353,6 +423,67 @@ fn handle_distribution_api(
|
||||||
let _ = request.respond(response);
|
let _ = request.respond(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handle_topology_api(
|
||||||
|
request: tiny_http::Request,
|
||||||
|
runtime: Arc<Mutex<Option<Arc<Runtime>>>>,
|
||||||
|
collector: Arc<Mutex<Option<Arc<StatsCollector>>>>,
|
||||||
|
) {
|
||||||
|
let maybe_rt = runtime.lock().unwrap().clone();
|
||||||
|
let json = match maybe_rt {
|
||||||
|
Some(rt) => {
|
||||||
|
let mut stats = rt.stats();
|
||||||
|
if let Some(col) = collector.lock().unwrap().as_ref() {
|
||||||
|
col.enrich(&mut stats);
|
||||||
|
}
|
||||||
|
let topo = topology::worker_topology(&stats);
|
||||||
|
serde_json::to_string(&topo).unwrap_or_else(|_| "{}".into())
|
||||||
|
}
|
||||||
|
None => "{}".to_string(),
|
||||||
|
};
|
||||||
|
let response = tiny_http::Response::from_string(json).with_header(
|
||||||
|
"Content-Type: application/json"
|
||||||
|
.parse::<tiny_http::Header>()
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
let _ = request.respond(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_logs_api(request: tiny_http::Request, url: &str, store: Arc<EventStore>) {
|
||||||
|
let params = parse_query_string(url);
|
||||||
|
let actor = params.get("actor").cloned().unwrap_or_default();
|
||||||
|
let limit: usize = params
|
||||||
|
.get("limit")
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(200);
|
||||||
|
let level = params.get("level").cloned();
|
||||||
|
|
||||||
|
let mut events = store.read_for_actor(&actor, limit);
|
||||||
|
|
||||||
|
// Filter by level if specified
|
||||||
|
if let Some(ref lvl) = level {
|
||||||
|
let lvl_upper = lvl.to_uppercase();
|
||||||
|
events.retain(|e| e.level == lvl_upper);
|
||||||
|
}
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&events).unwrap_or_else(|_| "[]".into());
|
||||||
|
let response = tiny_http::Response::from_string(json).with_header(
|
||||||
|
"Content-Type: application/json"
|
||||||
|
.parse::<tiny_http::Header>()
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
let _ = request.respond(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_history_api(request: tiny_http::Request, history: Arc<DashboardHistory>) {
|
||||||
|
let json = history.worker_history_json();
|
||||||
|
let response = tiny_http::Response::from_string(json).with_header(
|
||||||
|
"Content-Type: application/json"
|
||||||
|
.parse::<tiny_http::Header>()
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
let _ = request.respond(response);
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_query_string(url: &str) -> HashMap<String, String> {
|
fn parse_query_string(url: &str) -> HashMap<String, String> {
|
||||||
let mut params = HashMap::new();
|
let mut params = HashMap::new();
|
||||||
if let Some(qs) = url.split('?').nth(1) {
|
if let Some(qs) = url.split('?').nth(1) {
|
||||||
|
|
|
||||||
82
crates/runtime-dashboard/src/topology.rs
Normal file
82
crates/runtime-dashboard/src/topology.rs
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
//! Actor-to-actor (and worker-to-worker) message flow topology.
|
||||||
|
//!
|
||||||
|
//! Currently derives topology from per-worker cross_sends/local_sends stats.
|
||||||
|
//! Future: sample-based per-actor source→destination tracking with core instrumentation.
|
||||||
|
|
||||||
|
use swactor::stats::RuntimeStats;
|
||||||
|
|
||||||
|
/// An edge in the topology graph.
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
pub struct TopologyEdge {
|
||||||
|
pub source: String,
|
||||||
|
pub target: String,
|
||||||
|
pub weight: u64,
|
||||||
|
pub label: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A node in the topology graph.
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
pub struct TopologyNode {
|
||||||
|
pub id: String,
|
||||||
|
pub label: String,
|
||||||
|
pub actor_count: usize,
|
||||||
|
pub group: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A snapshot of the current topology.
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
pub struct TopologySnapshot {
|
||||||
|
pub nodes: Vec<TopologyNode>,
|
||||||
|
pub edges: Vec<TopologyEdge>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a worker-level topology from RuntimeStats.
|
||||||
|
///
|
||||||
|
/// Workers are nodes, edges represent message flow:
|
||||||
|
/// - Self-loops for local_sends
|
||||||
|
/// - Cross-edges distributed proportionally (until per-destination tracking exists)
|
||||||
|
pub fn worker_topology(stats: &RuntimeStats) -> TopologySnapshot {
|
||||||
|
let mut nodes = Vec::new();
|
||||||
|
let mut edges = Vec::new();
|
||||||
|
|
||||||
|
for w in &stats.workers {
|
||||||
|
nodes.push(TopologyNode {
|
||||||
|
id: format!("w{}", w.id),
|
||||||
|
label: format!("W{}", w.id),
|
||||||
|
actor_count: w.num_actors,
|
||||||
|
group: w.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Local sends = self-loop
|
||||||
|
if w.local_sends > 0 {
|
||||||
|
edges.push(TopologyEdge {
|
||||||
|
source: format!("w{}", w.id),
|
||||||
|
target: format!("w{}", w.id),
|
||||||
|
weight: w.local_sends,
|
||||||
|
label: format!("{} local", w.local_sends),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cross sends — without per-destination data, distribute evenly to other workers
|
||||||
|
if w.cross_sends > 0 && stats.workers.len() > 1 {
|
||||||
|
let others: Vec<&swactor::stats::WorkerInfo> =
|
||||||
|
stats.workers.iter().filter(|o| o.id != w.id).collect();
|
||||||
|
let per_worker = w.cross_sends / others.len() as u64;
|
||||||
|
let remainder = w.cross_sends % others.len() as u64;
|
||||||
|
|
||||||
|
for (i, other) in others.iter().enumerate() {
|
||||||
|
let count = per_worker + if (i as u64) < remainder { 1 } else { 0 };
|
||||||
|
if count > 0 {
|
||||||
|
edges.push(TopologyEdge {
|
||||||
|
source: format!("w{}", w.id),
|
||||||
|
target: format!("w{}", other.id),
|
||||||
|
weight: count,
|
||||||
|
label: format!("{} cross", count),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TopologySnapshot { nodes, edges }
|
||||||
|
}
|
||||||
280
crates/runtime-dashboard/src/topology_html.rs
Normal file
280
crates/runtime-dashboard/src/topology_html.rs
Normal file
|
|
@ -0,0 +1,280 @@
|
||||||
|
pub const TOPOLOGY_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>Topology — Swactor 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; }
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
.nav-link:hover { color: #e0e0e0; }
|
||||||
|
.nav-link.active { color: #fff; background: #2a2d3e; }
|
||||||
|
|
||||||
|
.content { padding: 0; display: flex; flex-direction: column; height: calc(100vh - 49px); }
|
||||||
|
canvas#topoCanvas { flex: 1; width: 100%; cursor: grab; }
|
||||||
|
canvas#topoCanvas:active { cursor: grabbing; }
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
padding: 8px 20px; background: #161822; border-top: 1px solid #2a2d3e;
|
||||||
|
font-size: 11px; color: #888;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<div class="header-left">
|
||||||
|
<h1>Swactor Runtime Dashboard <span id="statusDot" class="status-dot"></span></h1>
|
||||||
|
<nav class="nav-links">
|
||||||
|
<a href="/" class="nav-link">Overview</a>
|
||||||
|
<a href="/actors" class="nav-link">Actors</a>
|
||||||
|
<a href="/topology" class="nav-link active">Topology</a>
|
||||||
|
<a href="/distribution" class="nav-link">Distribution</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<canvas id="topoCanvas"></canvas>
|
||||||
|
<div class="legend">
|
||||||
|
Node size = actor count. Edge thickness = message volume. Green = local sends. Blue = cross-worker sends.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var canvas = document.getElementById('topoCanvas');
|
||||||
|
var ctx = canvas.getContext('2d');
|
||||||
|
var dot = document.getElementById('statusDot');
|
||||||
|
|
||||||
|
var colors = ['#4caf50','#2196f3','#ff9800','#f44336','#9c27b0','#00bcd4','#ffeb3b','#e91e63'];
|
||||||
|
var nodes = [];
|
||||||
|
var edges = [];
|
||||||
|
var positions = {};
|
||||||
|
|
||||||
|
function resize() {
|
||||||
|
var dpr = window.devicePixelRatio || 1;
|
||||||
|
var rect = canvas.getBoundingClientRect();
|
||||||
|
canvas.width = rect.width * dpr;
|
||||||
|
canvas.height = rect.height * dpr;
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', resize);
|
||||||
|
resize();
|
||||||
|
|
||||||
|
function initPositions() {
|
||||||
|
var W = canvas.getBoundingClientRect().width;
|
||||||
|
var H = canvas.getBoundingClientRect().height;
|
||||||
|
var cx = W / 2, cy = H / 2;
|
||||||
|
var r = Math.min(W, H) * 0.3;
|
||||||
|
|
||||||
|
nodes.forEach(function(n, i) {
|
||||||
|
if (!positions[n.id]) {
|
||||||
|
var angle = (2 * Math.PI * i) / Math.max(1, nodes.length);
|
||||||
|
positions[n.id] = {
|
||||||
|
x: cx + r * Math.cos(angle),
|
||||||
|
y: cy + r * Math.sin(angle),
|
||||||
|
vx: 0, vy: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function simulate() {
|
||||||
|
var W = canvas.getBoundingClientRect().width;
|
||||||
|
var H = canvas.getBoundingClientRect().height;
|
||||||
|
var cx = W / 2, cy = H / 2;
|
||||||
|
|
||||||
|
// Repulsion between nodes
|
||||||
|
for (var i = 0; i < nodes.length; i++) {
|
||||||
|
var pi = positions[nodes[i].id];
|
||||||
|
if (!pi) continue;
|
||||||
|
for (var j = i + 1; j < nodes.length; j++) {
|
||||||
|
var pj = positions[nodes[j].id];
|
||||||
|
if (!pj) continue;
|
||||||
|
var dx = pi.x - pj.x;
|
||||||
|
var dy = pi.y - pj.y;
|
||||||
|
var dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||||
|
var force = 8000 / (dist * dist);
|
||||||
|
pi.vx += dx / dist * force;
|
||||||
|
pi.vy += dy / dist * force;
|
||||||
|
pj.vx -= dx / dist * force;
|
||||||
|
pj.vy -= dy / dist * force;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attraction along edges
|
||||||
|
edges.forEach(function(e) {
|
||||||
|
if (e.source === e.target) return;
|
||||||
|
var ps = positions[e.source];
|
||||||
|
var pt = positions[e.target];
|
||||||
|
if (!ps || !pt) return;
|
||||||
|
var dx = pt.x - ps.x;
|
||||||
|
var dy = pt.y - ps.y;
|
||||||
|
var dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||||
|
var force = (dist - 150) * 0.01;
|
||||||
|
ps.vx += dx / dist * force;
|
||||||
|
ps.vy += dy / dist * force;
|
||||||
|
pt.vx -= dx / dist * force;
|
||||||
|
pt.vy -= dy / dist * force;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gravity toward center
|
||||||
|
for (var i = 0; i < nodes.length; i++) {
|
||||||
|
var p = positions[nodes[i].id];
|
||||||
|
if (!p) continue;
|
||||||
|
p.vx += (cx - p.x) * 0.002;
|
||||||
|
p.vy += (cy - p.y) * 0.002;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply velocity with damping
|
||||||
|
for (var i = 0; i < nodes.length; i++) {
|
||||||
|
var p = positions[nodes[i].id];
|
||||||
|
if (!p) continue;
|
||||||
|
p.vx *= 0.85;
|
||||||
|
p.vy *= 0.85;
|
||||||
|
p.x += p.vx;
|
||||||
|
p.y += p.vy;
|
||||||
|
p.x = Math.max(30, Math.min(W - 30, p.x));
|
||||||
|
p.y = Math.max(30, Math.min(H - 30, p.y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
var W = canvas.getBoundingClientRect().width;
|
||||||
|
var H = canvas.getBoundingClientRect().height;
|
||||||
|
ctx.clearRect(0, 0, W, H);
|
||||||
|
|
||||||
|
if (nodes.length === 0) {
|
||||||
|
ctx.fillStyle = '#555';
|
||||||
|
ctx.font = '14px monospace';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText('Waiting for topology data...', W / 2, H / 2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw edges
|
||||||
|
var maxWeight = Math.max(1, Math.max.apply(null, edges.map(function(e) { return e.weight; })));
|
||||||
|
|
||||||
|
edges.forEach(function(e) {
|
||||||
|
var ps = positions[e.source];
|
||||||
|
var pt = positions[e.target];
|
||||||
|
if (!ps || !pt) return;
|
||||||
|
|
||||||
|
var isSelf = e.source === e.target;
|
||||||
|
var thickness = Math.max(1, (e.weight / maxWeight) * 6);
|
||||||
|
var color = isSelf ? 'rgba(76, 175, 80, 0.5)' : 'rgba(33, 150, 243, 0.5)';
|
||||||
|
|
||||||
|
if (isSelf) {
|
||||||
|
// Self-loop: small arc above the node
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(ps.x, ps.y - 25, 15, 0.3, Math.PI - 0.3);
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.lineWidth = thickness;
|
||||||
|
ctx.stroke();
|
||||||
|
} else {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(ps.x, ps.y);
|
||||||
|
ctx.lineTo(pt.x, pt.y);
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.lineWidth = thickness;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Arrow
|
||||||
|
var angle = Math.atan2(pt.y - ps.y, pt.x - ps.x);
|
||||||
|
var headLen = 8;
|
||||||
|
var mx = (ps.x + pt.x) / 2;
|
||||||
|
var my = (ps.y + pt.y) / 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(mx, my);
|
||||||
|
ctx.lineTo(mx - headLen * Math.cos(angle - 0.3), my - headLen * Math.sin(angle - 0.3));
|
||||||
|
ctx.moveTo(mx, my);
|
||||||
|
ctx.lineTo(mx - headLen * Math.cos(angle + 0.3), my - headLen * Math.sin(angle + 0.3));
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Edge label
|
||||||
|
ctx.fillStyle = '#666';
|
||||||
|
ctx.font = '9px monospace';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(e.label, mx, my - 6);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Draw nodes
|
||||||
|
nodes.forEach(function(n) {
|
||||||
|
var p = positions[n.id];
|
||||||
|
if (!p) return;
|
||||||
|
var r = Math.max(12, 8 + n.actor_count * 2);
|
||||||
|
var color = colors[n.group % colors.length];
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(p.x, p.y, r, 0, 2 * Math.PI);
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.globalAlpha = 0.7;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
ctx.strokeStyle = '#fff';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.fillStyle = '#fff';
|
||||||
|
ctx.font = 'bold 11px monospace';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(n.label, p.x, p.y);
|
||||||
|
|
||||||
|
ctx.fillStyle = '#888';
|
||||||
|
ctx.font = '9px monospace';
|
||||||
|
ctx.fillText(n.actor_count + ' actors', p.x, p.y + r + 12);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTopology(data) {
|
||||||
|
nodes = data.nodes || [];
|
||||||
|
edges = data.edges || [];
|
||||||
|
initPositions();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
simulate();
|
||||||
|
draw();
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
tick();
|
||||||
|
|
||||||
|
var es = new EventSource('/events');
|
||||||
|
|
||||||
|
es.addEventListener('topology', function(e) {
|
||||||
|
try { updateTopology(JSON.parse(e.data)); } catch(err) { console.error(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener('done', function() {
|
||||||
|
dot.className = 'status-dot disconnected';
|
||||||
|
es.close();
|
||||||
|
});
|
||||||
|
es.onerror = function() { dot.className = 'status-dot disconnected'; };
|
||||||
|
es.onopen = function() { dot.className = 'status-dot'; };
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"##;
|
||||||
|
|
@ -1,9 +1,14 @@
|
||||||
|
use std::collections::{HashMap, VecDeque};
|
||||||
|
use std::sync::Arc;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
use swactor::actor::ActorAddress;
|
use swactor::actor::ActorAddress;
|
||||||
use swactor::stats::{RuntimeStats, TickTiming};
|
use swactor::stats::{RuntimeStats, TickTiming};
|
||||||
|
|
||||||
|
use crate::layer::{DashboardEvent, EventStore};
|
||||||
|
use crate::warnings::{Warning, WarningConfig, WarningDetector};
|
||||||
|
|
||||||
/// Per-worker data prepared for rendering.
|
/// Per-worker data prepared for rendering.
|
||||||
pub struct WorkerView {
|
pub struct WorkerView {
|
||||||
pub id: usize,
|
pub id: usize,
|
||||||
|
|
@ -15,6 +20,10 @@ pub struct WorkerView {
|
||||||
/// Fraction of bar for each phase group: [processing, delivery, spawns, overhead]
|
/// Fraction of bar for each phase group: [processing, delivery, spawns, overhead]
|
||||||
pub phase_fractions: [f64; 4],
|
pub phase_fractions: [f64; 4],
|
||||||
pub panics: u64,
|
pub panics: u64,
|
||||||
|
/// Recent message rates for sparkline rendering.
|
||||||
|
pub sparkline_rates: Vec<u64>,
|
||||||
|
/// Recent mailbox depths for sparkline rendering.
|
||||||
|
pub sparkline_mailbox: Vec<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One row in the actor table.
|
/// One row in the actor table.
|
||||||
|
|
@ -25,6 +34,12 @@ pub struct ActorRow {
|
||||||
pub last_msg_type: Option<String>,
|
pub last_msg_type: Option<String>,
|
||||||
pub messages_processed: u64,
|
pub messages_processed: u64,
|
||||||
pub poisoned: bool,
|
pub poisoned: bool,
|
||||||
|
/// Per-actor mailbox sparkline (from local ring buffer).
|
||||||
|
pub sparkline_mailbox: Vec<u64>,
|
||||||
|
/// Per-actor message rate sparkline.
|
||||||
|
pub sparkline_rates: Vec<u64>,
|
||||||
|
/// Per-message-type counts, sorted descending.
|
||||||
|
pub message_type_counts: Vec<(String, u64)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
|
@ -62,6 +77,7 @@ impl SortColumn {
|
||||||
pub enum ViewMode {
|
pub enum ViewMode {
|
||||||
Overview,
|
Overview,
|
||||||
WorkerDetail,
|
WorkerDetail,
|
||||||
|
ActorDetail,
|
||||||
#[cfg(feature = "distribution")]
|
#[cfg(feature = "distribution")]
|
||||||
Distribution,
|
Distribution,
|
||||||
}
|
}
|
||||||
|
|
@ -79,7 +95,13 @@ pub struct App {
|
||||||
pub total_panics: u64,
|
pub total_panics: u64,
|
||||||
pub num_workers: usize,
|
pub num_workers: usize,
|
||||||
pub view_mode: ViewMode,
|
pub view_mode: ViewMode,
|
||||||
|
pub prev_view_mode: ViewMode,
|
||||||
pub focused_worker: usize,
|
pub focused_worker: usize,
|
||||||
|
pub focused_actor: Option<ActorAddress>,
|
||||||
|
pub search_active: bool,
|
||||||
|
pub search_query: String,
|
||||||
|
pub search_locked: bool,
|
||||||
|
pub warnings: Vec<Warning>,
|
||||||
|
|
||||||
#[cfg(feature = "distribution")]
|
#[cfg(feature = "distribution")]
|
||||||
pub distribution: Option<distribution::snapshot::DistributionNodeSnapshot>,
|
pub distribution: Option<distribution::snapshot::DistributionNodeSnapshot>,
|
||||||
|
|
@ -90,6 +112,21 @@ pub struct App {
|
||||||
prev_time: Instant,
|
prev_time: Instant,
|
||||||
/// Rolling msg rates (smoothed)
|
/// Rolling msg rates (smoothed)
|
||||||
msg_rates: Vec<f64>,
|
msg_rates: Vec<f64>,
|
||||||
|
/// Per-worker sparkline history (message rate deltas).
|
||||||
|
sparkline_rates: Vec<VecDeque<u64>>,
|
||||||
|
/// Per-worker sparkline history (mailbox depths).
|
||||||
|
sparkline_mailbox: Vec<VecDeque<u64>>,
|
||||||
|
/// Per-actor sparkline history: address → (prev_msgs, rates, mailbox_depths).
|
||||||
|
actor_sparklines: HashMap<ActorAddress, (u64, VecDeque<u64>, VecDeque<u64>)>,
|
||||||
|
warning_detector: WarningDetector,
|
||||||
|
/// Event store for fetching per-actor logs.
|
||||||
|
event_store: Option<Arc<EventStore>>,
|
||||||
|
/// Cached log entries for the focused actor.
|
||||||
|
pub actor_logs: Vec<DashboardEvent>,
|
||||||
|
/// Scroll offset for actor log view.
|
||||||
|
pub log_scroll: usize,
|
||||||
|
/// Active log level filter (all enabled by default).
|
||||||
|
pub log_levels: [bool; 5], // ERROR, WARN, INFO, DEBUG, TRACE
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
|
|
@ -107,7 +144,13 @@ impl App {
|
||||||
total_panics: 0,
|
total_panics: 0,
|
||||||
num_workers: 0,
|
num_workers: 0,
|
||||||
view_mode: ViewMode::Overview,
|
view_mode: ViewMode::Overview,
|
||||||
|
prev_view_mode: ViewMode::Overview,
|
||||||
focused_worker: 0,
|
focused_worker: 0,
|
||||||
|
focused_actor: None,
|
||||||
|
search_active: false,
|
||||||
|
search_query: String::new(),
|
||||||
|
search_locked: false,
|
||||||
|
warnings: Vec::new(),
|
||||||
#[cfg(feature = "distribution")]
|
#[cfg(feature = "distribution")]
|
||||||
distribution: None,
|
distribution: None,
|
||||||
#[cfg(feature = "distribution")]
|
#[cfg(feature = "distribution")]
|
||||||
|
|
@ -115,9 +158,22 @@ impl App {
|
||||||
prev_messages: Vec::new(),
|
prev_messages: Vec::new(),
|
||||||
prev_time: Instant::now(),
|
prev_time: Instant::now(),
|
||||||
msg_rates: Vec::new(),
|
msg_rates: Vec::new(),
|
||||||
|
sparkline_rates: Vec::new(),
|
||||||
|
sparkline_mailbox: Vec::new(),
|
||||||
|
actor_sparklines: HashMap::new(),
|
||||||
|
warning_detector: WarningDetector::new(WarningConfig::default()),
|
||||||
|
event_store: None,
|
||||||
|
actor_logs: Vec::new(),
|
||||||
|
log_scroll: 0,
|
||||||
|
log_levels: [true; 5],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the event store for per-actor log retrieval.
|
||||||
|
pub fn set_event_store(&mut self, store: Arc<EventStore>) {
|
||||||
|
self.event_store = Some(store);
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "distribution")]
|
#[cfg(feature = "distribution")]
|
||||||
pub fn update_distribution(&mut self, snapshot: distribution::snapshot::DistributionNodeSnapshot) {
|
pub fn update_distribution(&mut self, snapshot: distribution::snapshot::DistributionNodeSnapshot) {
|
||||||
let max = if snapshot.members.is_empty() { 0 } else { snapshot.members.len() - 1 };
|
let max = if snapshot.members.is_empty() { 0 } else { snapshot.members.len() - 1 };
|
||||||
|
|
@ -125,6 +181,62 @@ impl App {
|
||||||
self.distribution = Some(snapshot);
|
self.distribution = Some(snapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get visible actor rows (filtered by search query if active).
|
||||||
|
pub fn visible_actor_rows(&self) -> Vec<&ActorRow> {
|
||||||
|
if self.search_query.is_empty() {
|
||||||
|
self.actor_rows.iter().collect()
|
||||||
|
} else {
|
||||||
|
let q = self.search_query.to_lowercase();
|
||||||
|
self.actor_rows.iter().filter(|r| {
|
||||||
|
let addr = format!("{}", r.address).to_lowercase();
|
||||||
|
let msg = r.last_msg_type.as_deref().unwrap_or("").to_lowercase();
|
||||||
|
let worker = format!("w{}", r.worker_id);
|
||||||
|
addr.contains(&q) || msg.contains(&q) || worker.contains(&q)
|
||||||
|
}).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the focused actor's data (for actor detail view).
|
||||||
|
pub fn focused_actor_row(&self) -> Option<&ActorRow> {
|
||||||
|
self.focused_actor.as_ref().and_then(|addr| {
|
||||||
|
self.actor_rows.iter().find(|r| r.address == *addr)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refresh the actor log cache from the event store.
|
||||||
|
pub fn refresh_actor_logs(&mut self) {
|
||||||
|
if let (Some(addr), Some(store)) = (&self.focused_actor, &self.event_store) {
|
||||||
|
let hex = format!("{}", addr);
|
||||||
|
self.actor_logs = store.read_for_actor(&hex, 200);
|
||||||
|
} else {
|
||||||
|
self.actor_logs.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get visible log entries (filtered by level).
|
||||||
|
pub fn visible_logs(&self) -> Vec<&DashboardEvent> {
|
||||||
|
self.actor_logs
|
||||||
|
.iter()
|
||||||
|
.filter(|e| {
|
||||||
|
match e.level.as_str() {
|
||||||
|
"ERROR" => self.log_levels[0],
|
||||||
|
"WARN" => self.log_levels[1],
|
||||||
|
"INFO" => self.log_levels[2],
|
||||||
|
"DEBUG" => self.log_levels[3],
|
||||||
|
"TRACE" => self.log_levels[4],
|
||||||
|
_ => true,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle a log level filter (0=ERROR, 1=WARN, 2=INFO, 3=DEBUG, 4=TRACE).
|
||||||
|
pub fn toggle_log_level(&mut self, idx: usize) {
|
||||||
|
if idx < 5 {
|
||||||
|
self.log_levels[idx] = !self.log_levels[idx];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Actor rows filtered to the focused worker (for worker detail view).
|
/// Actor rows filtered to the focused worker (for worker detail view).
|
||||||
pub fn focused_actor_rows(&self) -> Vec<&ActorRow> {
|
pub fn focused_actor_rows(&self) -> Vec<&ActorRow> {
|
||||||
self.actor_rows
|
self.actor_rows
|
||||||
|
|
@ -144,6 +256,8 @@ impl App {
|
||||||
if self.prev_messages.len() != stats.workers.len() {
|
if self.prev_messages.len() != stats.workers.len() {
|
||||||
self.prev_messages = stats.workers.iter().map(|w| w.messages_processed).collect();
|
self.prev_messages = stats.workers.iter().map(|w| w.messages_processed).collect();
|
||||||
self.msg_rates = vec![0.0; stats.workers.len()];
|
self.msg_rates = vec![0.0; stats.workers.len()];
|
||||||
|
self.sparkline_rates.resize_with(stats.workers.len(), VecDeque::new);
|
||||||
|
self.sparkline_mailbox.resize_with(stats.workers.len(), VecDeque::new);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute per-worker views
|
// Compute per-worker views
|
||||||
|
|
@ -163,8 +277,19 @@ impl App {
|
||||||
self.msg_rates[i] * 0.6 + rate * 0.4
|
self.msg_rates[i] * 0.6 + rate * 0.4
|
||||||
};
|
};
|
||||||
self.msg_rates[i] = smoothed;
|
self.msg_rates[i] = smoothed;
|
||||||
|
|
||||||
|
// Track sparkline history
|
||||||
|
let delta = w.messages_processed.saturating_sub(self.prev_messages[i]);
|
||||||
self.prev_messages[i] = w.messages_processed;
|
self.prev_messages[i] = w.messages_processed;
|
||||||
|
|
||||||
|
let spark_rates = &mut self.sparkline_rates[i];
|
||||||
|
if spark_rates.len() >= 60 { spark_rates.pop_front(); }
|
||||||
|
spark_rates.push_back(delta);
|
||||||
|
|
||||||
|
let spark_mbox = &mut self.sparkline_mailbox[i];
|
||||||
|
if spark_mbox.len() >= 60 { spark_mbox.pop_front(); }
|
||||||
|
spark_mbox.push_back(w.mailbox_depth as u64);
|
||||||
|
|
||||||
// Load % and phase fractions from tick timings
|
// Load % and phase fractions from tick timings
|
||||||
let timings = stats.tick_timings.get(i).map(|v| v.as_slice()).unwrap_or(&[]);
|
let timings = stats.tick_timings.get(i).map(|v| v.as_slice()).unwrap_or(&[]);
|
||||||
let (load_pct, phase_fractions) = compute_load_and_phases(timings);
|
let (load_pct, phase_fractions) = compute_load_and_phases(timings);
|
||||||
|
|
@ -178,6 +303,8 @@ impl App {
|
||||||
load_pct,
|
load_pct,
|
||||||
phase_fractions,
|
phase_fractions,
|
||||||
panics: w.panics,
|
panics: w.panics,
|
||||||
|
sparkline_rates: spark_rates.iter().copied().collect(),
|
||||||
|
sparkline_mailbox: spark_mbox.iter().copied().collect(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -187,9 +314,20 @@ impl App {
|
||||||
self.total_mailbox = stats.workers.iter().map(|w| w.mailbox_depth).sum();
|
self.total_mailbox = stats.workers.iter().map(|w| w.mailbox_depth).sum();
|
||||||
self.total_panics = stats.workers.iter().map(|w| w.panics).sum();
|
self.total_panics = stats.workers.iter().map(|w| w.panics).sum();
|
||||||
|
|
||||||
// Build actor table
|
// Build actor table with sparkline history
|
||||||
self.actor_rows.clear();
|
self.actor_rows.clear();
|
||||||
for a in &stats.actor_details {
|
for a in &stats.actor_details {
|
||||||
|
let (prev, rates_buf, mbox_buf) = self.actor_sparklines
|
||||||
|
.entry(a.address)
|
||||||
|
.or_insert_with(|| (0, VecDeque::new(), VecDeque::new()));
|
||||||
|
|
||||||
|
let rate_delta = a.messages_processed.saturating_sub(*prev);
|
||||||
|
*prev = a.messages_processed;
|
||||||
|
if rates_buf.len() >= 60 { rates_buf.pop_front(); }
|
||||||
|
rates_buf.push_back(rate_delta);
|
||||||
|
if mbox_buf.len() >= 60 { mbox_buf.pop_front(); }
|
||||||
|
mbox_buf.push_back(a.mailbox_depth as u64);
|
||||||
|
|
||||||
self.actor_rows.push(ActorRow {
|
self.actor_rows.push(ActorRow {
|
||||||
address: a.address,
|
address: a.address,
|
||||||
worker_id: a.worker_id,
|
worker_id: a.worker_id,
|
||||||
|
|
@ -197,10 +335,21 @@ impl App {
|
||||||
last_msg_type: a.last_msg_type.clone(),
|
last_msg_type: a.last_msg_type.clone(),
|
||||||
messages_processed: a.messages_processed,
|
messages_processed: a.messages_processed,
|
||||||
poisoned: a.poisoned,
|
poisoned: a.poisoned,
|
||||||
|
sparkline_rates: rates_buf.iter().copied().collect(),
|
||||||
|
sparkline_mailbox: mbox_buf.iter().copied().collect(),
|
||||||
|
message_type_counts: a.message_type_counts.clone(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
self.sort_actors();
|
self.sort_actors();
|
||||||
|
|
||||||
|
// Run warning detection
|
||||||
|
self.warnings = self.warning_detector.check(&stats);
|
||||||
|
|
||||||
|
// Refresh actor logs if in detail view
|
||||||
|
if self.view_mode == ViewMode::ActorDetail {
|
||||||
|
self.refresh_actor_logs();
|
||||||
|
}
|
||||||
|
|
||||||
// Clamp selection
|
// Clamp selection
|
||||||
if !self.actor_rows.is_empty() {
|
if !self.actor_rows.is_empty() {
|
||||||
self.selected = self.selected.min(self.actor_rows.len() - 1);
|
self.selected = self.selected.min(self.actor_rows.len() - 1);
|
||||||
|
|
@ -246,12 +395,45 @@ impl App {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn handle_key(&mut self, key: KeyEvent) {
|
pub fn handle_key(&mut self, key: KeyEvent) {
|
||||||
|
// Search mode input handling
|
||||||
|
if self.search_active {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc => {
|
||||||
|
self.search_active = false;
|
||||||
|
if !self.search_locked {
|
||||||
|
self.search_query.clear();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
self.search_active = false;
|
||||||
|
self.search_locked = !self.search_query.is_empty();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
self.search_query.pop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
self.search_query.push(c);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_ => return,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Global keys
|
// Global keys
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Char('q') => { self.should_quit = true; return; }
|
KeyCode::Char('q') => { self.should_quit = true; return; }
|
||||||
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
self.should_quit = true; return;
|
self.should_quit = true; return;
|
||||||
}
|
}
|
||||||
|
KeyCode::Char('/') => {
|
||||||
|
self.search_active = true;
|
||||||
|
self.search_locked = false;
|
||||||
|
self.search_query.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
KeyCode::Char('s') => {
|
KeyCode::Char('s') => {
|
||||||
self.sort_column = self.sort_column.next();
|
self.sort_column = self.sort_column.next();
|
||||||
self.sort_actors();
|
self.sort_actors();
|
||||||
|
|
@ -265,6 +447,7 @@ impl App {
|
||||||
KeyCode::Tab => {
|
KeyCode::Tab => {
|
||||||
self.view_mode = match self.view_mode {
|
self.view_mode = match self.view_mode {
|
||||||
ViewMode::Overview => ViewMode::WorkerDetail,
|
ViewMode::Overview => ViewMode::WorkerDetail,
|
||||||
|
ViewMode::ActorDetail => ViewMode::Overview,
|
||||||
#[cfg(feature = "distribution")]
|
#[cfg(feature = "distribution")]
|
||||||
ViewMode::WorkerDetail => ViewMode::Distribution,
|
ViewMode::WorkerDetail => ViewMode::Distribution,
|
||||||
#[cfg(not(feature = "distribution"))]
|
#[cfg(not(feature = "distribution"))]
|
||||||
|
|
@ -280,13 +463,15 @@ impl App {
|
||||||
match self.view_mode {
|
match self.view_mode {
|
||||||
ViewMode::Overview => self.handle_key_overview(key),
|
ViewMode::Overview => self.handle_key_overview(key),
|
||||||
ViewMode::WorkerDetail => self.handle_key_worker_detail(key),
|
ViewMode::WorkerDetail => self.handle_key_worker_detail(key),
|
||||||
|
ViewMode::ActorDetail => self.handle_key_actor_detail(key),
|
||||||
#[cfg(feature = "distribution")]
|
#[cfg(feature = "distribution")]
|
||||||
ViewMode::Distribution => self.handle_key_distribution(key),
|
ViewMode::Distribution => self.handle_key_distribution(key),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_key_overview(&mut self, key: KeyEvent) {
|
fn handle_key_overview(&mut self, key: KeyEvent) {
|
||||||
let max = if self.actor_rows.is_empty() { 0 } else { self.actor_rows.len() - 1 };
|
let visible = self.visible_actor_rows();
|
||||||
|
let max = if visible.is_empty() { 0 } else { visible.len() - 1 };
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Up | KeyCode::Char('k') => {
|
KeyCode::Up | KeyCode::Char('k') => {
|
||||||
self.selected = self.selected.saturating_sub(1);
|
self.selected = self.selected.saturating_sub(1);
|
||||||
|
|
@ -303,11 +488,14 @@ impl App {
|
||||||
KeyCode::Home => { self.selected = 0; }
|
KeyCode::Home => { self.selected = 0; }
|
||||||
KeyCode::End => { self.selected = max; }
|
KeyCode::End => { self.selected = max; }
|
||||||
KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => {
|
KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => {
|
||||||
// Enter worker detail for the selected actor's worker
|
// Enter actor detail for the selected visible actor
|
||||||
if let Some(row) = self.actor_rows.get(self.selected) {
|
if let Some(&row) = visible.get(self.selected) {
|
||||||
self.focused_worker = row.worker_id;
|
self.focused_actor = Some(row.address);
|
||||||
|
self.prev_view_mode = ViewMode::Overview;
|
||||||
|
self.view_mode = ViewMode::ActorDetail;
|
||||||
|
self.log_scroll = 0;
|
||||||
|
self.refresh_actor_logs();
|
||||||
}
|
}
|
||||||
self.view_mode = ViewMode::WorkerDetail;
|
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
@ -337,6 +525,38 @@ impl App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handle_key_actor_detail(&mut self, key: KeyEvent) {
|
||||||
|
let max_scroll = self.visible_logs().len().saturating_sub(1);
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc | KeyCode::Char('h') | KeyCode::Left => {
|
||||||
|
self.view_mode = self.prev_view_mode;
|
||||||
|
self.focused_actor = None;
|
||||||
|
self.actor_logs.clear();
|
||||||
|
self.log_scroll = 0;
|
||||||
|
}
|
||||||
|
KeyCode::Down | KeyCode::Char('j') => {
|
||||||
|
self.log_scroll = (self.log_scroll + 1).min(max_scroll);
|
||||||
|
}
|
||||||
|
KeyCode::Up | KeyCode::Char('k') => {
|
||||||
|
self.log_scroll = self.log_scroll.saturating_sub(1);
|
||||||
|
}
|
||||||
|
KeyCode::PageDown => {
|
||||||
|
self.log_scroll = (self.log_scroll + 20).min(max_scroll);
|
||||||
|
}
|
||||||
|
KeyCode::PageUp => {
|
||||||
|
self.log_scroll = self.log_scroll.saturating_sub(20);
|
||||||
|
}
|
||||||
|
KeyCode::Home => { self.log_scroll = 0; }
|
||||||
|
KeyCode::End => { self.log_scroll = max_scroll; }
|
||||||
|
KeyCode::Char('1') => self.toggle_log_level(0),
|
||||||
|
KeyCode::Char('2') => self.toggle_log_level(1),
|
||||||
|
KeyCode::Char('3') => self.toggle_log_level(2),
|
||||||
|
KeyCode::Char('4') => self.toggle_log_level(3),
|
||||||
|
KeyCode::Char('5') => self.toggle_log_level(4),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "distribution")]
|
#[cfg(feature = "distribution")]
|
||||||
fn handle_key_distribution(&mut self, key: KeyEvent) {
|
fn handle_key_distribution(&mut self, key: KeyEvent) {
|
||||||
let max = self
|
let max = self
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ use swactor::runtime::Runtime;
|
||||||
use crate::collector::StatsCollector;
|
use crate::collector::StatsCollector;
|
||||||
#[cfg(feature = "distribution")]
|
#[cfg(feature = "distribution")]
|
||||||
use crate::distribution_collector::DistributionStatsProvider;
|
use crate::distribution_collector::DistributionStatsProvider;
|
||||||
|
use crate::layer::EventStore;
|
||||||
use self::app::App;
|
use self::app::App;
|
||||||
use self::event::{AppEvent, EventLoop};
|
use self::event::{AppEvent, EventLoop};
|
||||||
use self::types::RuntimeEndpoint;
|
use self::types::RuntimeEndpoint;
|
||||||
|
|
@ -47,6 +48,7 @@ pub fn start_tui(
|
||||||
#[cfg(feature = "distribution")]
|
#[cfg(feature = "distribution")]
|
||||||
distribution: Option<Arc<dyn DistributionStatsProvider>>,
|
distribution: Option<Arc<dyn DistributionStatsProvider>>,
|
||||||
config: TuiConfig,
|
config: TuiConfig,
|
||||||
|
event_store: Option<Arc<EventStore>>,
|
||||||
) -> io::Result<()> {
|
) -> io::Result<()> {
|
||||||
// Set up terminal
|
// Set up terminal
|
||||||
crossterm::terminal::enable_raw_mode()?;
|
crossterm::terminal::enable_raw_mode()?;
|
||||||
|
|
@ -72,6 +74,7 @@ pub fn start_tui(
|
||||||
#[cfg(feature = "distribution")]
|
#[cfg(feature = "distribution")]
|
||||||
distribution,
|
distribution,
|
||||||
config,
|
config,
|
||||||
|
event_store,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Restore terminal
|
// Restore terminal
|
||||||
|
|
@ -92,8 +95,12 @@ fn run_loop(
|
||||||
#[cfg(feature = "distribution")]
|
#[cfg(feature = "distribution")]
|
||||||
distribution: Option<Arc<dyn DistributionStatsProvider>>,
|
distribution: Option<Arc<dyn DistributionStatsProvider>>,
|
||||||
config: TuiConfig,
|
config: TuiConfig,
|
||||||
|
event_store: Option<Arc<EventStore>>,
|
||||||
) -> io::Result<()> {
|
) -> io::Result<()> {
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
|
if let Some(store) = event_store {
|
||||||
|
app.set_event_store(store);
|
||||||
|
}
|
||||||
let mut table_state = TableState::default();
|
let mut table_state = TableState::default();
|
||||||
let events = EventLoop::new(config.poll_interval_ms);
|
let events = EventLoop::new(config.poll_interval_ms);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ use ratatui::Frame;
|
||||||
use ratatui::layout::{Constraint, Layout, Rect};
|
use ratatui::layout::{Constraint, Layout, Rect};
|
||||||
use ratatui::style::{Color, Modifier, Style};
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState};
|
use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Sparkline, Table, TableState};
|
||||||
|
|
||||||
use super::app::{App, SortColumn, ViewMode};
|
use super::app::{App, SortColumn, ViewMode};
|
||||||
|
|
||||||
|
|
@ -11,6 +11,7 @@ pub fn draw(f: &mut Frame, app: &App, table_state: &mut TableState) {
|
||||||
match app.view_mode {
|
match app.view_mode {
|
||||||
ViewMode::Overview => draw_overview(f, app, table_state),
|
ViewMode::Overview => draw_overview(f, app, table_state),
|
||||||
ViewMode::WorkerDetail => draw_worker_detail(f, app, table_state),
|
ViewMode::WorkerDetail => draw_worker_detail(f, app, table_state),
|
||||||
|
ViewMode::ActorDetail => draw_actor_detail(f, app),
|
||||||
#[cfg(feature = "distribution")]
|
#[cfg(feature = "distribution")]
|
||||||
ViewMode::Distribution => draw_distribution(f, app, table_state),
|
ViewMode::Distribution => draw_distribution(f, app, table_state),
|
||||||
}
|
}
|
||||||
|
|
@ -20,17 +21,23 @@ pub fn draw(f: &mut Frame, app: &App, table_state: &mut TableState) {
|
||||||
|
|
||||||
fn draw_overview(f: &mut Frame, app: &App, table_state: &mut TableState) {
|
fn draw_overview(f: &mut Frame, app: &App, table_state: &mut TableState) {
|
||||||
let num_workers = app.workers.len().max(1);
|
let num_workers = app.workers.len().max(1);
|
||||||
|
let has_sparkline_data = app.workers.iter().any(|w| w.sparkline_rates.len() > 1);
|
||||||
|
let sparkline_height = if has_sparkline_data { 4u16 } else { 0u16 };
|
||||||
|
|
||||||
let chunks = Layout::vertical([
|
let chunks = Layout::vertical([
|
||||||
Constraint::Length(num_workers as u16),
|
Constraint::Length(num_workers as u16),
|
||||||
|
Constraint::Length(sparkline_height),
|
||||||
Constraint::Length(1),
|
Constraint::Length(1),
|
||||||
Constraint::Fill(1),
|
Constraint::Fill(1),
|
||||||
])
|
])
|
||||||
.split(f.area());
|
.split(f.area());
|
||||||
|
|
||||||
draw_worker_bars(f, app, chunks[0]);
|
draw_worker_bars(f, app, chunks[0]);
|
||||||
draw_summary(f, app, chunks[1]);
|
if has_sparkline_data {
|
||||||
draw_actor_table(f, app, table_state, chunks[2]);
|
draw_worker_sparklines(f, app, chunks[1]);
|
||||||
|
}
|
||||||
|
draw_summary(f, app, chunks[2]);
|
||||||
|
draw_actor_table(f, app, table_state, chunks[3]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render htop-style worker bars.
|
/// Render htop-style worker bars.
|
||||||
|
|
@ -45,6 +52,36 @@ fn draw_worker_bars(f: &mut Frame, app: &App, area: Rect) {
|
||||||
f.render_widget(paragraph, area);
|
f.render_widget(paragraph, area);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Render per-worker sparklines showing message rate trends.
|
||||||
|
fn draw_worker_sparklines(f: &mut Frame, app: &App, area: Rect) {
|
||||||
|
if app.workers.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Split area horizontally: one sparkline per worker
|
||||||
|
let constraints: Vec<Constraint> = app
|
||||||
|
.workers
|
||||||
|
.iter()
|
||||||
|
.map(|_| Constraint::Ratio(1, app.workers.len() as u32))
|
||||||
|
.collect();
|
||||||
|
let cols = Layout::horizontal(constraints).split(area);
|
||||||
|
|
||||||
|
for (i, w) in app.workers.iter().enumerate() {
|
||||||
|
if let Some(&col_area) = cols.get(i) {
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::NONE)
|
||||||
|
.title(Span::styled(
|
||||||
|
format!(" W{} ", w.id),
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
));
|
||||||
|
let sparkline = Sparkline::default()
|
||||||
|
.block(block)
|
||||||
|
.data(&w.sparkline_rates)
|
||||||
|
.style(Style::default().fg(Color::Green));
|
||||||
|
f.render_widget(sparkline, col_area);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn build_worker_line(w: &super::app::WorkerView, total_width: usize) -> Line<'static> {
|
fn build_worker_line(w: &super::app::WorkerView, total_width: usize) -> Line<'static> {
|
||||||
let id_str = format!("{:>3}", w.id);
|
let id_str = format!("{:>3}", w.id);
|
||||||
let suffix = format!(
|
let suffix = format!(
|
||||||
|
|
@ -129,7 +166,7 @@ fn draw_summary(f: &mut Frame, app: &App, area: Rect) {
|
||||||
Style::default().fg(Color::DarkGray)
|
Style::default().fg(Color::DarkGray)
|
||||||
};
|
};
|
||||||
|
|
||||||
let line = Line::from(vec![
|
let mut spans = vec![
|
||||||
Span::styled(" Workers: ", Style::default().fg(Color::DarkGray)),
|
Span::styled(" Workers: ", Style::default().fg(Color::DarkGray)),
|
||||||
Span::styled(
|
Span::styled(
|
||||||
format!("{}", app.num_workers),
|
format!("{}", app.num_workers),
|
||||||
|
|
@ -152,13 +189,47 @@ fn draw_summary(f: &mut Frame, app: &App, area: Rect) {
|
||||||
),
|
),
|
||||||
Span::styled(" Panics: ", Style::default().fg(Color::DarkGray)),
|
Span::styled(" Panics: ", Style::default().fg(Color::DarkGray)),
|
||||||
Span::styled(format!("{}", app.total_panics), panics_style),
|
Span::styled(format!("{}", app.total_panics), panics_style),
|
||||||
]);
|
];
|
||||||
|
|
||||||
|
if !app.warnings.is_empty() {
|
||||||
|
spans.push(Span::styled(" Warnings: ", Style::default().fg(Color::DarkGray)));
|
||||||
|
spans.push(Span::styled(
|
||||||
|
format!("{}", app.warnings.len()),
|
||||||
|
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let line = Line::from(spans);
|
||||||
f.render_widget(Paragraph::new(line), area);
|
f.render_widget(Paragraph::new(line), area);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render the actor table with scrolling and selection.
|
/// Render the actor table with scrolling, selection, and search.
|
||||||
fn draw_actor_table(f: &mut Frame, app: &App, table_state: &mut TableState, area: Rect) {
|
fn draw_actor_table(f: &mut Frame, app: &App, table_state: &mut TableState, area: Rect) {
|
||||||
|
// Split area: optional search bar + table
|
||||||
|
let has_search = app.search_active || !app.search_query.is_empty();
|
||||||
|
let search_height = if has_search { 1u16 } else { 0 };
|
||||||
|
let chunks = Layout::vertical([
|
||||||
|
Constraint::Length(search_height),
|
||||||
|
Constraint::Fill(1),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
// Draw search bar
|
||||||
|
if has_search {
|
||||||
|
let search_style = if app.search_active {
|
||||||
|
Style::default().fg(Color::Yellow)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::DarkGray)
|
||||||
|
};
|
||||||
|
let cursor = if app.search_active { "\u{2588}" } else { "" };
|
||||||
|
let prefix = if app.search_locked { " [locked] /" } else { " /" };
|
||||||
|
let line = Line::from(vec![
|
||||||
|
Span::styled(prefix, Style::default().fg(Color::DarkGray)),
|
||||||
|
Span::styled(format!("{}{}", app.search_query, cursor), search_style),
|
||||||
|
]);
|
||||||
|
f.render_widget(Paragraph::new(line), chunks[0]);
|
||||||
|
}
|
||||||
|
|
||||||
let sort_arrow = if app.sort_desc { " \u{25bc}" } else { " \u{25b2}" };
|
let sort_arrow = if app.sort_desc { " \u{25bc}" } else { " \u{25b2}" };
|
||||||
|
|
||||||
let columns = [
|
let columns = [
|
||||||
|
|
@ -181,8 +252,8 @@ fn draw_actor_table(f: &mut Frame, app: &App, table_state: &mut TableState, area
|
||||||
});
|
});
|
||||||
let header = Row::new(header_cells).height(1);
|
let header = Row::new(header_cells).height(1);
|
||||||
|
|
||||||
let rows: Vec<Row> = app
|
let visible = app.visible_actor_rows();
|
||||||
.actor_rows
|
let rows: Vec<Row> = visible
|
||||||
.iter()
|
.iter()
|
||||||
.map(|a| actor_row_cells(a))
|
.map(|a| actor_row_cells(a))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
@ -190,7 +261,22 @@ fn draw_actor_table(f: &mut Frame, app: &App, table_state: &mut TableState, area
|
||||||
table_state.select(Some(app.selected));
|
table_state.select(Some(app.selected));
|
||||||
|
|
||||||
let help_text =
|
let help_text =
|
||||||
" q: quit \u{2191}\u{2193}: scroll s: sort column r: reverse Tab: worker view Enter: drill in";
|
" q: quit /: search \u{2191}\u{2193}: scroll s: sort r: reverse Tab: worker view Enter: detail";
|
||||||
|
|
||||||
|
let title = if !app.search_query.is_empty() {
|
||||||
|
format!(
|
||||||
|
" Actors ({} of {} matching \"{}\") ",
|
||||||
|
visible.len(),
|
||||||
|
app.actor_rows.len(),
|
||||||
|
app.search_query,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
" Actors (sorted by {}{}) ",
|
||||||
|
app.sort_column.label(),
|
||||||
|
sort_arrow
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
let table = Table::new(
|
let table = Table::new(
|
||||||
rows,
|
rows,
|
||||||
|
|
@ -206,11 +292,7 @@ fn draw_actor_table(f: &mut Frame, app: &App, table_state: &mut TableState, area
|
||||||
.block(
|
.block(
|
||||||
Block::default()
|
Block::default()
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.title(format!(
|
.title(title)
|
||||||
" Actors (sorted by {}{}) ",
|
|
||||||
app.sort_column.label(),
|
|
||||||
sort_arrow
|
|
||||||
))
|
|
||||||
.title_bottom(Line::from(help_text).centered()),
|
.title_bottom(Line::from(help_text).centered()),
|
||||||
)
|
)
|
||||||
.row_highlight_style(
|
.row_highlight_style(
|
||||||
|
|
@ -443,6 +525,231 @@ fn draw_focused_actor_table(f: &mut Frame, app: &App, table_state: &mut TableSta
|
||||||
f.render_stateful_widget(table, area, table_state);
|
f.render_stateful_widget(table, area, table_state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Actor Detail View ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn draw_actor_detail(f: &mut Frame, app: &App) {
|
||||||
|
let actor = match app.focused_actor_row() {
|
||||||
|
Some(a) => a,
|
||||||
|
None => {
|
||||||
|
let msg = Paragraph::new(" No actor selected. Press Esc to go back.")
|
||||||
|
.style(Style::default().fg(Color::DarkGray));
|
||||||
|
f.render_widget(msg, f.area());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let has_types = !actor.message_type_counts.is_empty();
|
||||||
|
let type_height = if has_types {
|
||||||
|
(actor.message_type_counts.len() as u16 + 2).min(10)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
let chunks = Layout::vertical([
|
||||||
|
Constraint::Length(5), // Info card
|
||||||
|
Constraint::Length(5), // Rate sparkline
|
||||||
|
Constraint::Length(5), // Mailbox sparkline
|
||||||
|
Constraint::Length(type_height), // Type breakdown
|
||||||
|
Constraint::Length(1), // Help bar
|
||||||
|
Constraint::Fill(1), // Logs panel
|
||||||
|
])
|
||||||
|
.split(f.area());
|
||||||
|
|
||||||
|
// Info card
|
||||||
|
let addr_str = format!("{}", actor.address);
|
||||||
|
let status = if actor.poisoned { "POISONED" } else { "Healthy" };
|
||||||
|
let status_color = if actor.poisoned { Color::Red } else { Color::Green };
|
||||||
|
let msg_type = actor.last_msg_type.as_deref()
|
||||||
|
.map(|s| short_type_name(Some(s)))
|
||||||
|
.unwrap_or_else(|| "\u{2014}".to_string());
|
||||||
|
|
||||||
|
let info_lines = vec![
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(" Address: ", Style::default().fg(Color::DarkGray)),
|
||||||
|
Span::styled(addr_str, Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(" Worker: ", Style::default().fg(Color::DarkGray)),
|
||||||
|
Span::styled(format!("W{}", actor.worker_id), Style::default().fg(Color::Cyan)),
|
||||||
|
Span::styled(" Status: ", Style::default().fg(Color::DarkGray)),
|
||||||
|
Span::styled(status, Style::default().fg(status_color).add_modifier(Modifier::BOLD)),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(" Messages: ", Style::default().fg(Color::DarkGray)),
|
||||||
|
Span::styled(
|
||||||
|
format_num(actor.messages_processed),
|
||||||
|
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::styled(" Mailbox: ", Style::default().fg(Color::DarkGray)),
|
||||||
|
Span::styled(
|
||||||
|
format!("{}", actor.mailbox_depth),
|
||||||
|
Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::styled(" Last Msg: ", Style::default().fg(Color::DarkGray)),
|
||||||
|
Span::styled(msg_type, Style::default().fg(Color::Green)),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
let info_block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(" Actor Detail ");
|
||||||
|
let info = Paragraph::new(info_lines).block(info_block);
|
||||||
|
f.render_widget(info, chunks[0]);
|
||||||
|
|
||||||
|
// Rate sparkline
|
||||||
|
let rate_block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(Span::styled(" Msg Rate ", Style::default().fg(Color::Green)));
|
||||||
|
let rate_sparkline = Sparkline::default()
|
||||||
|
.block(rate_block)
|
||||||
|
.data(&actor.sparkline_rates)
|
||||||
|
.style(Style::default().fg(Color::Green));
|
||||||
|
f.render_widget(rate_sparkline, chunks[1]);
|
||||||
|
|
||||||
|
// Mailbox sparkline
|
||||||
|
let mbox_block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(Span::styled(" Mailbox Depth ", Style::default().fg(Color::Blue)));
|
||||||
|
let mbox_sparkline = Sparkline::default()
|
||||||
|
.block(mbox_block)
|
||||||
|
.data(&actor.sparkline_mailbox)
|
||||||
|
.style(Style::default().fg(Color::Blue));
|
||||||
|
f.render_widget(mbox_sparkline, chunks[2]);
|
||||||
|
|
||||||
|
// Message type breakdown
|
||||||
|
if has_types {
|
||||||
|
draw_type_breakdown(f, &actor.message_type_counts, chunks[3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Help bar
|
||||||
|
let level_names = ["ERR", "WARN", "INFO", "DBG", "TRC"];
|
||||||
|
let level_colors = [Color::Red, Color::Yellow, Color::Blue, Color::DarkGray, Color::DarkGray];
|
||||||
|
let mut help_spans: Vec<Span> = vec![
|
||||||
|
Span::styled(
|
||||||
|
" Esc: back \u{2191}\u{2193}: scroll logs ",
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (i, &name) in level_names.iter().enumerate() {
|
||||||
|
let active = app.log_levels[i];
|
||||||
|
let color = if active { level_colors[i] } else { Color::DarkGray };
|
||||||
|
let style = if active {
|
||||||
|
Style::default().fg(color).add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(color)
|
||||||
|
};
|
||||||
|
help_spans.push(Span::styled(format!("{}:{} ", i + 1, name), style));
|
||||||
|
}
|
||||||
|
f.render_widget(Paragraph::new(Line::from(help_spans)), chunks[4]);
|
||||||
|
|
||||||
|
// Logs panel
|
||||||
|
draw_actor_logs(f, app, chunks[5]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_type_breakdown(f: &mut Frame, types: &[(String, u64)], area: Rect) {
|
||||||
|
let total: u64 = types.iter().map(|(_, c)| *c).sum();
|
||||||
|
let max_count = types.first().map(|(_, c)| *c).unwrap_or(1).max(1);
|
||||||
|
let inner_height = area.height.saturating_sub(2) as usize;
|
||||||
|
|
||||||
|
let bar_colors = [Color::Green, Color::Blue, Color::Yellow, Color::Magenta, Color::Cyan, Color::Red];
|
||||||
|
|
||||||
|
let lines: Vec<Line> = types
|
||||||
|
.iter()
|
||||||
|
.take(inner_height)
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, (name, count))| {
|
||||||
|
let short = name.rsplit("::").next().unwrap_or(name);
|
||||||
|
let pct = if total > 0 { *count as f64 / total as f64 * 100.0 } else { 0.0 };
|
||||||
|
let bar_width = 20usize;
|
||||||
|
let filled = ((*count as f64 / max_count as f64) * bar_width as f64).round() as usize;
|
||||||
|
let color = bar_colors[i % bar_colors.len()];
|
||||||
|
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
format!(" {:>16} ", short),
|
||||||
|
Style::default().fg(Color::White),
|
||||||
|
),
|
||||||
|
Span::styled(
|
||||||
|
"\u{2588}".repeat(filled),
|
||||||
|
Style::default().fg(color),
|
||||||
|
),
|
||||||
|
Span::styled(
|
||||||
|
" ".repeat(bar_width.saturating_sub(filled)),
|
||||||
|
Style::default(),
|
||||||
|
),
|
||||||
|
Span::styled(
|
||||||
|
format!(" {:>8} ", count),
|
||||||
|
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::styled(
|
||||||
|
format!("{:>5.1}%", pct),
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(format!(" Message Types ({}) ", types.len()));
|
||||||
|
let paragraph = Paragraph::new(lines).block(block);
|
||||||
|
f.render_widget(paragraph, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_actor_logs(f: &mut Frame, app: &App, area: Rect) {
|
||||||
|
let visible = app.visible_logs();
|
||||||
|
let log_count = visible.len();
|
||||||
|
let inner_height = area.height.saturating_sub(2) as usize; // borders
|
||||||
|
|
||||||
|
// Compute scroll window
|
||||||
|
let scroll = app.log_scroll.min(log_count.saturating_sub(inner_height));
|
||||||
|
|
||||||
|
let lines: Vec<Line> = visible
|
||||||
|
.iter()
|
||||||
|
.skip(scroll)
|
||||||
|
.take(inner_height)
|
||||||
|
.map(|e| {
|
||||||
|
let level_color = match e.level.as_str() {
|
||||||
|
"ERROR" => Color::Red,
|
||||||
|
"WARN" => Color::Yellow,
|
||||||
|
"INFO" => Color::Blue,
|
||||||
|
"DEBUG" => Color::DarkGray,
|
||||||
|
"TRACE" => Color::DarkGray,
|
||||||
|
_ => Color::White,
|
||||||
|
};
|
||||||
|
let ts = {
|
||||||
|
let secs = e.timestamp_ms / 1000;
|
||||||
|
let ms = e.timestamp_ms % 1000;
|
||||||
|
let h = (secs / 3600) % 24;
|
||||||
|
let m = (secs / 60) % 60;
|
||||||
|
let s = secs % 60;
|
||||||
|
format!("{:02}:{:02}:{:02}.{:03}", h, m, s, ms)
|
||||||
|
};
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
format!(" {} ", ts),
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
),
|
||||||
|
Span::styled(
|
||||||
|
format!("{:<5} ", e.level),
|
||||||
|
Style::default().fg(level_color).add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::styled(
|
||||||
|
e.message.clone(),
|
||||||
|
Style::default().fg(Color::White),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let title = format!(" Logs ({}) ", log_count);
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(title);
|
||||||
|
let paragraph = Paragraph::new(lines).block(block);
|
||||||
|
f.render_widget(paragraph, area);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Distribution View ──────────────────────────────────────────────────────
|
// ─── Distribution View ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[cfg(feature = "distribution")]
|
#[cfg(feature = "distribution")]
|
||||||
|
|
|
||||||
392
crates/runtime-dashboard/src/warnings.rs
Normal file
392
crates/runtime-dashboard/src/warnings.rs
Normal file
|
|
@ -0,0 +1,392 @@
|
||||||
|
//! Automated anomaly detection for the runtime dashboard.
|
||||||
|
//!
|
||||||
|
//! Runs on each stats sample, comparing consecutive snapshots to detect
|
||||||
|
//! growing mailboxes, stalled actors, worker imbalance, and other conditions.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use swactor::actor::ActorAddress;
|
||||||
|
use swactor::stats::RuntimeStats;
|
||||||
|
|
||||||
|
/// Types of warnings the detector can produce.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum WarningType {
|
||||||
|
GrowingMailbox,
|
||||||
|
StalledActor,
|
||||||
|
PoisonedActor,
|
||||||
|
WorkerImbalance,
|
||||||
|
EmptyWorker,
|
||||||
|
MailboxOverflow,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Severity levels for warnings.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Severity {
|
||||||
|
Low,
|
||||||
|
Medium,
|
||||||
|
High,
|
||||||
|
Critical,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An active warning.
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
pub struct Warning {
|
||||||
|
pub warning_type: WarningType,
|
||||||
|
pub severity: Severity,
|
||||||
|
pub entity: String,
|
||||||
|
pub description: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration for warning thresholds.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct WarningConfig {
|
||||||
|
/// Consecutive samples with increasing mailbox depth before warning.
|
||||||
|
pub growing_mailbox_threshold: usize,
|
||||||
|
/// Consecutive ticks with no message processing while mailbox > 0.
|
||||||
|
pub stalled_actor_threshold: usize,
|
||||||
|
/// A worker is "imbalanced" if it has > this ratio times the average load.
|
||||||
|
pub worker_imbalance_ratio: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for WarningConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
growing_mailbox_threshold: 5,
|
||||||
|
stalled_actor_threshold: 10,
|
||||||
|
worker_imbalance_ratio: 2.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-actor tracking state.
|
||||||
|
struct ActorState {
|
||||||
|
prev_mailbox: usize,
|
||||||
|
prev_messages: u64,
|
||||||
|
growing_streak: usize,
|
||||||
|
stalled_streak: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Warning detection engine. Call `check()` on each stats sample.
|
||||||
|
pub struct WarningDetector {
|
||||||
|
config: WarningConfig,
|
||||||
|
actors: HashMap<ActorAddress, ActorState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WarningDetector {
|
||||||
|
pub fn new(config: WarningConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
actors: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Analyze a stats snapshot and return all active warnings.
|
||||||
|
pub fn check(&mut self, stats: &RuntimeStats) -> Vec<Warning> {
|
||||||
|
let mut warnings = Vec::new();
|
||||||
|
|
||||||
|
// Track which actors are still alive
|
||||||
|
let mut live_addrs: std::collections::HashSet<ActorAddress> =
|
||||||
|
std::collections::HashSet::new();
|
||||||
|
|
||||||
|
for actor in &stats.actor_details {
|
||||||
|
live_addrs.insert(actor.address);
|
||||||
|
|
||||||
|
// Poisoned actor — immediate critical warning
|
||||||
|
if actor.poisoned {
|
||||||
|
warnings.push(Warning {
|
||||||
|
warning_type: WarningType::PoisonedActor,
|
||||||
|
severity: Severity::Critical,
|
||||||
|
entity: format!("{}", actor.address),
|
||||||
|
description: "Actor is poisoned (panicked)".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = self.actors.entry(actor.address).or_insert(ActorState {
|
||||||
|
prev_mailbox: actor.mailbox_depth,
|
||||||
|
prev_messages: actor.messages_processed,
|
||||||
|
growing_streak: 0,
|
||||||
|
stalled_streak: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Growing mailbox detection
|
||||||
|
if actor.mailbox_depth > state.prev_mailbox && actor.mailbox_depth > 0 {
|
||||||
|
state.growing_streak += 1;
|
||||||
|
} else {
|
||||||
|
state.growing_streak = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.growing_streak >= self.config.growing_mailbox_threshold {
|
||||||
|
warnings.push(Warning {
|
||||||
|
warning_type: WarningType::GrowingMailbox,
|
||||||
|
severity: Severity::Medium,
|
||||||
|
entity: format!("{}", actor.address),
|
||||||
|
description: format!(
|
||||||
|
"Mailbox growing for {} consecutive samples (depth: {})",
|
||||||
|
state.growing_streak, actor.mailbox_depth,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stalled actor detection
|
||||||
|
if actor.messages_processed == state.prev_messages && actor.mailbox_depth > 0 {
|
||||||
|
state.stalled_streak += 1;
|
||||||
|
} else {
|
||||||
|
state.stalled_streak = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.stalled_streak >= self.config.stalled_actor_threshold {
|
||||||
|
warnings.push(Warning {
|
||||||
|
warning_type: WarningType::StalledActor,
|
||||||
|
severity: Severity::High,
|
||||||
|
entity: format!("{}", actor.address),
|
||||||
|
description: format!(
|
||||||
|
"No messages processed for {} ticks with {} pending",
|
||||||
|
state.stalled_streak, actor.mailbox_depth,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
state.prev_mailbox = actor.mailbox_depth;
|
||||||
|
state.prev_messages = actor.messages_processed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up dead actors
|
||||||
|
self.actors.retain(|addr, _| live_addrs.contains(addr));
|
||||||
|
|
||||||
|
// Mailbox overflow detection
|
||||||
|
for w in &stats.workers {
|
||||||
|
if w.messages_dropped > 0 {
|
||||||
|
warnings.push(Warning {
|
||||||
|
warning_type: WarningType::MailboxOverflow,
|
||||||
|
severity: Severity::Medium,
|
||||||
|
entity: format!("Worker {}", w.id),
|
||||||
|
description: format!("{} messages dropped", w.messages_dropped),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Worker imbalance and empty worker detection
|
||||||
|
if stats.workers.len() > 1 {
|
||||||
|
let total_actors: usize = stats.workers.iter().map(|w| w.num_actors).sum();
|
||||||
|
let avg = total_actors as f64 / stats.workers.len() as f64;
|
||||||
|
|
||||||
|
for w in &stats.workers {
|
||||||
|
if avg > 0.0 && w.num_actors as f64 > avg * self.config.worker_imbalance_ratio {
|
||||||
|
warnings.push(Warning {
|
||||||
|
warning_type: WarningType::WorkerImbalance,
|
||||||
|
severity: Severity::Low,
|
||||||
|
entity: format!("Worker {}", w.id),
|
||||||
|
description: format!(
|
||||||
|
"{} actors vs {:.0} average ({:.1}x)",
|
||||||
|
w.num_actors, avg, w.num_actors as f64 / avg,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if w.num_actors == 0 && total_actors > 0 {
|
||||||
|
warnings.push(Warning {
|
||||||
|
warning_type: WarningType::EmptyWorker,
|
||||||
|
severity: Severity::Low,
|
||||||
|
entity: format!("Worker {}", w.id),
|
||||||
|
description: "Worker has no actors while others do".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by severity (critical first)
|
||||||
|
warnings.sort_by(|a, b| b.severity.cmp(&a.severity));
|
||||||
|
warnings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use swactor::stats::{ActorInfo, WorkerInfo};
|
||||||
|
|
||||||
|
fn make_worker(id: usize, actors: usize, dropped: u64) -> WorkerInfo {
|
||||||
|
WorkerInfo {
|
||||||
|
id,
|
||||||
|
num_actors: actors,
|
||||||
|
mailbox_depth: 0,
|
||||||
|
messages_processed: 0,
|
||||||
|
local_sends: 0,
|
||||||
|
cross_sends: 0,
|
||||||
|
inbox_sends: 0,
|
||||||
|
type_mismatches: 0,
|
||||||
|
panics: 0,
|
||||||
|
messages_dropped: dropped,
|
||||||
|
restarts: 0,
|
||||||
|
stops: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_actor(id: u8, depth: usize, msgs: u64, poisoned: bool) -> ActorInfo {
|
||||||
|
ActorInfo {
|
||||||
|
address: ActorAddress([id; 32]),
|
||||||
|
worker_id: 0,
|
||||||
|
mailbox_depth: depth,
|
||||||
|
last_msg_type: None,
|
||||||
|
messages_processed: msgs,
|
||||||
|
poisoned,
|
||||||
|
message_type_counts: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_stats(workers: Vec<WorkerInfo>, actors: Vec<ActorInfo>) -> RuntimeStats {
|
||||||
|
RuntimeStats {
|
||||||
|
num_workers: workers.len(),
|
||||||
|
uptime_ms: 0,
|
||||||
|
actors: actors.iter().map(|a| (a.address, a.worker_id)).collect(),
|
||||||
|
workers,
|
||||||
|
actor_details: actors,
|
||||||
|
tick_timings: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn poisoned_actor_triggers_critical_warning() {
|
||||||
|
let mut detector = WarningDetector::new(WarningConfig::default());
|
||||||
|
let stats = make_stats(
|
||||||
|
vec![make_worker(0, 1, 0)],
|
||||||
|
vec![make_actor(1, 0, 10, true)],
|
||||||
|
);
|
||||||
|
let warnings = detector.check(&stats);
|
||||||
|
assert!(warnings.iter().any(|w| w.warning_type == WarningType::PoisonedActor));
|
||||||
|
assert!(warnings.iter().any(|w| w.severity == Severity::Critical));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn growing_mailbox_triggers_after_threshold() {
|
||||||
|
let config = WarningConfig {
|
||||||
|
growing_mailbox_threshold: 3,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut detector = WarningDetector::new(config);
|
||||||
|
|
||||||
|
// 4 samples with increasing mailbox: should trigger at sample 4
|
||||||
|
for depth in 1..=4 {
|
||||||
|
let stats = make_stats(
|
||||||
|
vec![make_worker(0, 1, 0)],
|
||||||
|
vec![make_actor(1, depth, 0, false)],
|
||||||
|
);
|
||||||
|
let warnings = detector.check(&stats);
|
||||||
|
if depth < 4 {
|
||||||
|
assert!(!warnings.iter().any(|w| w.warning_type == WarningType::GrowingMailbox),
|
||||||
|
"should not trigger at depth {}", depth);
|
||||||
|
} else {
|
||||||
|
assert!(warnings.iter().any(|w| w.warning_type == WarningType::GrowingMailbox),
|
||||||
|
"should trigger at depth {}", depth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn growing_mailbox_resets_on_decrease() {
|
||||||
|
let config = WarningConfig {
|
||||||
|
growing_mailbox_threshold: 3,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut detector = WarningDetector::new(config);
|
||||||
|
|
||||||
|
// Grow for 2 samples, then decrease, then grow again
|
||||||
|
for depth in [1, 2, 1, 2, 3, 4] {
|
||||||
|
let stats = make_stats(
|
||||||
|
vec![make_worker(0, 1, 0)],
|
||||||
|
vec![make_actor(1, depth, 0, false)],
|
||||||
|
);
|
||||||
|
detector.check(&stats);
|
||||||
|
}
|
||||||
|
// After 1,2 → streak=2; then 1 → streak=0; then 2,3,4 → streak=3 → triggers
|
||||||
|
let stats = make_stats(
|
||||||
|
vec![make_worker(0, 1, 0)],
|
||||||
|
vec![make_actor(1, 5, 0, false)],
|
||||||
|
);
|
||||||
|
let warnings = detector.check(&stats);
|
||||||
|
assert!(warnings.iter().any(|w| w.warning_type == WarningType::GrowingMailbox));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stalled_actor_triggers_when_not_processing() {
|
||||||
|
let config = WarningConfig {
|
||||||
|
stalled_actor_threshold: 3,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut detector = WarningDetector::new(config);
|
||||||
|
|
||||||
|
// Same messages_processed, nonzero mailbox for 4 ticks
|
||||||
|
for _ in 0..4 {
|
||||||
|
let stats = make_stats(
|
||||||
|
vec![make_worker(0, 1, 0)],
|
||||||
|
vec![make_actor(1, 5, 100, false)],
|
||||||
|
);
|
||||||
|
let warnings = detector.check(&stats);
|
||||||
|
// Last one should trigger
|
||||||
|
if warnings.iter().any(|w| w.warning_type == WarningType::StalledActor) {
|
||||||
|
return; // test passed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panic!("expected StalledActor warning");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_imbalance_detected() {
|
||||||
|
let mut detector = WarningDetector::new(WarningConfig::default());
|
||||||
|
// Worker 0: 10 actors, Worker 1: 1 actor. Avg=5.5, ratio=10/5.5=1.8
|
||||||
|
// With ratio threshold 2.0, this should NOT trigger
|
||||||
|
let stats = make_stats(
|
||||||
|
vec![make_worker(0, 10, 0), make_worker(1, 1, 0)],
|
||||||
|
vec![],
|
||||||
|
);
|
||||||
|
let warnings = detector.check(&stats);
|
||||||
|
assert!(!warnings.iter().any(|w| w.warning_type == WarningType::WorkerImbalance));
|
||||||
|
|
||||||
|
// Worker 0: 20 actors, Worker 1: 1 actor. Avg=10.5, ratio=20/10.5=1.9 — still no
|
||||||
|
// Worker 0: 30 actors, Worker 1: 1 actor. Avg=15.5, ratio=30/15.5=1.9 — still no
|
||||||
|
// Worker 0: 100 actors, Worker 1: 1 actor. Avg=50.5, ratio=100/50.5=1.98 — almost
|
||||||
|
// Worker 0: 100 actors, Worker 1: 0 actor. Avg=50, ratio=100/50=2.0 — at threshold
|
||||||
|
|
||||||
|
let stats2 = make_stats(
|
||||||
|
vec![make_worker(0, 100, 0), make_worker(1, 1, 0)],
|
||||||
|
vec![],
|
||||||
|
);
|
||||||
|
let warnings2 = detector.check(&stats2);
|
||||||
|
// 100 / 50.5 = 1.98 — not > 2.0
|
||||||
|
assert!(!warnings2.iter().any(|w| w.warning_type == WarningType::WorkerImbalance));
|
||||||
|
|
||||||
|
// Now 200 vs 1: 200/100.5 = ~1.99 — still not. Let's do 300 vs 1: 300/150.5 = ~2.0
|
||||||
|
// Actually need > 2x. Let's do 50 vs 1: avg=25.5, ratio=50/25.5=1.96. Nope.
|
||||||
|
// 10 vs 1 vs 1: avg=4, ratio=10/4=2.5 — triggers!
|
||||||
|
let stats3 = make_stats(
|
||||||
|
vec![make_worker(0, 10, 0), make_worker(1, 1, 0), make_worker(2, 1, 0)],
|
||||||
|
vec![],
|
||||||
|
);
|
||||||
|
let warnings3 = detector.check(&stats3);
|
||||||
|
assert!(warnings3.iter().any(|w| w.warning_type == WarningType::WorkerImbalance));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_worker_detected() {
|
||||||
|
let mut detector = WarningDetector::new(WarningConfig::default());
|
||||||
|
let stats = make_stats(
|
||||||
|
vec![make_worker(0, 5, 0), make_worker(1, 0, 0)],
|
||||||
|
vec![],
|
||||||
|
);
|
||||||
|
let warnings = detector.check(&stats);
|
||||||
|
assert!(warnings.iter().any(|w| w.warning_type == WarningType::EmptyWorker));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mailbox_overflow_detected() {
|
||||||
|
let mut detector = WarningDetector::new(WarningConfig::default());
|
||||||
|
let stats = make_stats(
|
||||||
|
vec![make_worker(0, 1, 42)],
|
||||||
|
vec![],
|
||||||
|
);
|
||||||
|
let warnings = detector.check(&stats);
|
||||||
|
assert!(warnings.iter().any(|w| w.warning_type == WarningType::MailboxOverflow));
|
||||||
|
}
|
||||||
|
}
|
||||||
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 swactor::Error;
|
||||||
|
|
||||||
use crate::StdExtension;
|
use crate::StdExtension;
|
||||||
|
use crate::timer_wheel::{CloneMsg, TimerRequest};
|
||||||
|
|
||||||
fn get_ext<'a>(ctx: &'a Ctx) -> &'a StdExtension {
|
fn get_ext<'a>(ctx: &'a Ctx) -> &'a StdExtension {
|
||||||
ctx.extension()
|
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`].
|
/// Group extension for [`Ctx`].
|
||||||
///
|
///
|
||||||
/// Provides `join_group`, `leave_group`, `publish`, and `group_members` via
|
/// Provides `join_group`, `leave_group`, `publish`, and `group_members` via
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,21 @@
|
||||||
use std::any::Any;
|
use std::any::Any;
|
||||||
|
|
||||||
use swactor::actor::{ActorAddress, Down, StopReason};
|
use swactor::actor::{ActorAddress, Down, ExitReason, StopReason};
|
||||||
use swactor::extension::RuntimeExtension;
|
use swactor::extension::{RuntimeExtension, WorkerExtension};
|
||||||
|
|
||||||
use crate::group_registry::GroupRegistry;
|
use crate::group_registry::GroupRegistry;
|
||||||
use crate::monitor_registry::MonitorRegistry;
|
use crate::monitor_registry::MonitorRegistry;
|
||||||
use crate::name_registry::NameRegistry;
|
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()))`.
|
/// Install on a `Runtime` via `runtime.with_extension(Arc::new(StdExtension::new()))`.
|
||||||
pub struct StdExtension {
|
pub struct StdExtension {
|
||||||
pub(crate) name_registry: NameRegistry,
|
pub(crate) name_registry: NameRegistry,
|
||||||
pub(crate) monitor_registry: MonitorRegistry,
|
pub(crate) monitor_registry: MonitorRegistry,
|
||||||
|
pub(crate) watch_registry: WatchRegistry,
|
||||||
pub(crate) group_registry: GroupRegistry,
|
pub(crate) group_registry: GroupRegistry,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -21,6 +24,7 @@ impl StdExtension {
|
||||||
Self {
|
Self {
|
||||||
name_registry: NameRegistry::new(),
|
name_registry: NameRegistry::new(),
|
||||||
monitor_registry: MonitorRegistry::new(),
|
monitor_registry: MonitorRegistry::new(),
|
||||||
|
watch_registry: WatchRegistry::new(),
|
||||||
group_registry: GroupRegistry::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 {
|
impl RuntimeExtension for StdExtension {
|
||||||
fn on_actor_death(
|
fn on_actor_death(
|
||||||
&self,
|
&self,
|
||||||
dead: &[(ActorAddress, StopReason)],
|
dead: &[(ActorAddress, StopReason)],
|
||||||
) -> Vec<(ActorAddress, Box<dyn Any + Send>)> {
|
) -> Vec<(ActorAddress, Box<dyn Any + Send>)> {
|
||||||
let mut notifications = Vec::new();
|
let mut notifications = Vec::new();
|
||||||
|
|
||||||
for &(addr, reason) in dead {
|
for &(addr, reason) in dead {
|
||||||
|
// Monitor notifications (Down)
|
||||||
let watchers = self.monitor_registry.take_monitors(&addr);
|
let watchers = self.monitor_registry.take_monitors(&addr);
|
||||||
for (_mref, watcher) in watchers {
|
for (_mref, watcher) in watchers {
|
||||||
let down = Down { addr, reason };
|
let down = Down { addr, reason };
|
||||||
notifications.push((watcher, Box::new(down) as Box<dyn Any + Send>));
|
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
|
notifications
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -53,10 +74,15 @@ impl RuntimeExtension for StdExtension {
|
||||||
self.name_registry.unregister_by_addr(addr);
|
self.name_registry.unregister_by_addr(addr);
|
||||||
self.group_registry.cleanup(addr);
|
self.group_registry.cleanup(addr);
|
||||||
self.monitor_registry.remove_watcher(addr);
|
self.monitor_registry.remove_watcher(addr);
|
||||||
|
self.watch_registry.cleanup_watcher(addr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn as_any(&self) -> &dyn Any {
|
fn as_any(&self) -> &dyn Any {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn create_worker_extension(&self) -> Option<Box<dyn WorkerExtension>> {
|
||||||
|
Some(Box::new(TimerWheel::new()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@ mod supervisor;
|
||||||
mod router;
|
mod router;
|
||||||
pub mod name_registry;
|
pub mod name_registry;
|
||||||
pub mod monitor_registry;
|
pub mod monitor_registry;
|
||||||
|
pub mod watch_registry;
|
||||||
pub mod group_registry;
|
pub mod group_registry;
|
||||||
|
pub(crate) mod timer_wheel;
|
||||||
mod extension;
|
mod extension;
|
||||||
mod ctx_ext;
|
mod ctx_ext;
|
||||||
mod runtime_ext;
|
mod runtime_ext;
|
||||||
|
|
@ -10,5 +12,5 @@ mod runtime_ext;
|
||||||
pub use supervisor::{ChildSpec, RestartPolicy, Supervisor, SupervisorStrategy};
|
pub use supervisor::{ChildSpec, RestartPolicy, Supervisor, SupervisorStrategy};
|
||||||
pub use router::{Router, RoutingStrategy};
|
pub use router::{Router, RoutingStrategy};
|
||||||
pub use extension::StdExtension;
|
pub use extension::StdExtension;
|
||||||
pub use ctx_ext::{CtxMonitoring, CtxNaming, CtxGroups};
|
pub use ctx_ext::{CtxMonitoring, CtxNaming, CtxGroups, CtxWatching, CtxTimers};
|
||||||
pub use runtime_ext::{RuntimeNaming, RuntimeGroups};
|
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`].
|
/// Group extension for [`Runtime`].
|
||||||
///
|
///
|
||||||
/// Provides `join_group`, `leave_group`, `publish_to`, `group_members`,
|
/// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
docs/development_history/dashboard-improvements-research.md
Normal file
28
docs/development_history/dashboard-improvements-research.md
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
# Dashboard Improvements — Research Phase
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
Researched 10 comparable monitoring/dashboard systems to inform swactor's dashboard improvement plan.
|
||||||
|
|
||||||
|
## Systems Analyzed
|
||||||
|
- **Actor runtimes**: Erlang Observer (GUI/CLI/Web), Akka Insights, Ray Dashboard, Orleans Dashboard
|
||||||
|
- **Async/runtime tools**: tokio-console, Lunatic
|
||||||
|
- **Message/infrastructure**: RabbitMQ Management, Consul UI, Nomad UI
|
||||||
|
- **Web frameworks**: Phoenix LiveDashboard
|
||||||
|
|
||||||
|
## Key Findings
|
||||||
|
1. **Time-series history** is table-stakes — every system provides it
|
||||||
|
2. **Actor detail drill-down** is universal (Observer has 6-tab process info, Orleans has grain state inspection)
|
||||||
|
3. **Search/filter** exists in every system
|
||||||
|
4. **Warning/anomaly detection** (tokio-console's lint system) is a high-value differentiator
|
||||||
|
5. **Topology visualization** (Consul golden metrics, Observer supervision tree) is rare but powerful
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
8 feature stages defined (see `CLAUDE/notes/feature-stages/`):
|
||||||
|
1. Time-Series History Infrastructure
|
||||||
|
2. Actor Detail Drill-Down
|
||||||
|
3. Search and Filter
|
||||||
|
4. Per-Worker Utilization Visualization
|
||||||
|
5. Warning/Anomaly Detection
|
||||||
|
6. Actor-to-Actor Message Flow Topology
|
||||||
|
7. Per-Actor Logging
|
||||||
|
8. Per-Message-Type Breakdown
|
||||||
|
|
@ -16,11 +16,11 @@ messages.
|
||||||
│ │ │ │
|
│ │ │ │
|
||||||
│ │ address_map: Arc<AddressMap> -- actor -> worker lookup │ │
|
│ │ address_map: Arc<AddressMap> -- actor -> worker lookup │ │
|
||||||
│ │ inbox_registry: Arc<InboxRegistry> -- external inbox delivery │ │
|
│ │ 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 │ │
|
│ │ placement: Placement -- load-aware worker picker │ │
|
||||||
│ │ worker_stats: Vec<Arc<WorkerStats>> -- atomic stat counters │ │
|
│ │ 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 │
|
│ inner: &dyn ContextInner -- polymorphic dispatch │
|
||||||
│ self_addr: ActorAddress -- address of the current actor │
|
│ self_addr: ActorAddress -- address of the current actor │
|
||||||
│ │
|
│ │
|
||||||
│ ┌─ Public API ────────────────────────────────────────────────────────┐ │
|
│ ┌─ Core API ─────────────────────────────────────────────────────────┐ │
|
||||||
│ │ │ │
|
│ │ │ │
|
||||||
│ │ ctx.self_addr() -> ActorAddress │ │
|
│ │ ctx.self_addr() -> ActorAddress │ │
|
||||||
│ │ ctx.send(addr, msg) -> Result<(), Error> │ │
|
│ │ ctx.send(addr, msg) -> Result<(), Error> │ │
|
||||||
│ │ ctx.spawn(actor) -> Result<ActorAddress, 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_self() │ │
|
||||||
│ │ ctx.stop_actor(addr) -> Result<(), Error> │ │
|
│ │ ctx.stop_actor(addr) -> Result<(), Error> │ │
|
||||||
│ │ ctx.where_is(name) -> Option<ActorAddress> │ │
|
│ │ ctx.extension() -> Option<&dyn RuntimeExtension> │ │
|
||||||
│ │ ctx.monitor(target) -> MonitorRef │ │
|
│ │ │ │
|
||||||
│ │ ctx.demonitor(mref) │ │
|
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||||
│ │ ctx.join_group(group) │ │
|
│ │
|
||||||
│ │ ctx.leave_group(group) │ │
|
│ ┌─ Extension Traits (swactor-std) ──────────────────────────────────┐ │
|
||||||
│ │ ctx.publish(group, msg) -> usize │ │
|
│ │ │ │
|
||||||
│ │ ctx.group_members(group) -> Vec<ActorAddress> │ │
|
│ │ CtxNaming: spawn_named, where_is │ │
|
||||||
│ │ ctx.send_after_ticks(addr, msg, n) │ │
|
│ │ CtxMonitoring: monitor, demonitor │ │
|
||||||
│ │ ctx.send_interval_ticks(addr, msg, period) │ │
|
│ │ 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 ─────────────────────────────────────────────┐ │
|
│ ┌─ ContextInner dispatch ─────────────────────────────────────────────┐ │
|
||||||
│ │ │ │
|
│ │ │ │
|
||||||
|
│ │ Five methods: send_any, spawn_any, request_stop, │ │
|
||||||
|
│ │ post_worker_request, extension │ │
|
||||||
|
│ │ │ │
|
||||||
│ │ In single-threaded mode: inner = &Runtime │ │
|
│ │ In single-threaded mode: inner = &Runtime │ │
|
||||||
│ │ send → transfer_txs[wid], spawn → spawn_txs[wid] │ │
|
│ │ send → transfer_txs[wid], spawn → spawn_txs[wid] │ │
|
||||||
│ │ │ │
|
│ │ │ │
|
||||||
│ │ In multi-threaded mode: inner = &WorkerContext │ │
|
│ │ In multi-threaded mode: inner = &WorkerContext │ │
|
||||||
│ │ send → pending_local (same worker) or transfer_txs (cross) │ │
|
│ │ send → pending_local (same worker) or transfer_txs (cross) │ │
|
||||||
│ │ spawn → spawn_txs[target_wid] │ │
|
│ │ 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
|
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_after_ticks(addr, msg, 5); // one-shot: fires after 5 ticks
|
||||||
ctx.send_interval_ticks(addr, msg, 10); // repeating: every 10 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
|
## RuntimeHandle
|
||||||
|
|
||||||
Returned by `run()`. Holds `Arc<Runtime>` and the thread `JoinHandle`s.
|
Returned by `run()`. Holds `Arc<Runtime>` and the thread `JoinHandle`s.
|
||||||
|
|
|
||||||
|
|
@ -48,10 +48,12 @@
|
||||||
│ │ │ │
|
│ │ │ │
|
||||||
│ └──────────────────────────────────────────────────────────────────┘ │
|
│ └──────────────────────────────────────────────────────────────────┘ │
|
||||||
│ │
|
│ │
|
||||||
│ ┌─ TimerWheel ────────────────────────────────────────────────────┐ │
|
│ ┌─ worker_ext: Option<Box<dyn WorkerExtension>> ─────────────────┐ │
|
||||||
│ │ current_tick: u64 │ │
|
│ │ Per-worker extension state, created by RuntimeExtension │ │
|
||||||
│ │ once_timers: Vec<OnceTimer> -- fire_at, dest, msg │ │
|
│ │ factory. StdExtension provides a TimerWheel here. │ │
|
||||||
│ │ interval_timers: Vec<IntervalTimer> -- period, dest, clone_msg │ │
|
│ │ 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 │
|
│ spawn_txs: &[Sender] -- one Sender per worker │
|
||||||
│ placement: &Placement -- load-aware worker picker │
|
│ placement: &Placement -- load-aware worker picker │
|
||||||
│ inbox_registry: &InboxRegistry -- external Inbox<M> receivers │
|
│ 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. │
|
│ 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 │
|
│ 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 │
|
│ 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 ││
|
│ │ v ││
|
||||||
│ │ for (dest, msg) in timer_msgs: ││
|
│ │ for (dest, msg) in ext_msgs: ││
|
||||||
│ │ ┌──────────────┬──────────────┬─────────────────┐ ││
|
│ │ route_to_pool_or_remote(pool, tc, dest, msg) ││
|
||||||
│ │ │ local actor │ other worker │ inbox/unknown │ ││
|
│ │ local → pool.deliver | cross → transfer_tx | → inbox_registry ││
|
||||||
│ │ │ │ │ │ ││
|
|
||||||
│ │ │ pool.deliver │ transfer_tx │ inbox_registry │ ││
|
|
||||||
│ │ │ │ + unpark │ .try_deliver() │ ││
|
|
||||||
│ │ └──────────────┴──────────────┴─────────────────┘ ││
|
|
||||||
│ │ ││
|
│ │ ││
|
||||||
│ └────────────────────────────────────────────────────────────────────┘│
|
│ └────────────────────────────────────────────────────────────────────┘│
|
||||||
│ │ │
|
│ │ │
|
||||||
|
|
@ -171,7 +168,7 @@ Lives on `Arc<Runtime>`, shared read-only across all worker threads.
|
||||||
│ │ │ implements ContextInner │ ││
|
│ │ │ implements ContextInner │ ││
|
||||||
│ │ │ pending_local: RefCell<Vec<(Addr, Box<Any>)>> │ ││
|
│ │ │ pending_local: RefCell<Vec<(Addr, Box<Any>)>> │ ││
|
||||||
│ │ │ stop_requests: RefCell<Vec<ActorAddress>> │ ││
|
│ │ │ stop_requests: RefCell<Vec<ActorAddress>> │ ││
|
||||||
│ │ │ timer_requests: RefCell<Vec<TimerRequest>> │ ││
|
│ │ │ worker_requests: RefCell<Vec<Box<dyn Any + Send>>> │ ││
|
||||||
│ │ └────────────────────────────────────────────────────────────┘ ││
|
│ │ └────────────────────────────────────────────────────────────┘ ││
|
||||||
│ │ ││
|
│ │ ││
|
||||||
│ │ for each (addr, slot) in pool: ││
|
│ │ 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 │ ││
|
│ │ │ spawn_txs[wid].send((addr, actor)) + unpark │ ││
|
||||||
│ │ │ │ ││
|
│ │ │ │ ││
|
||||||
│ │ │ request_stop(addr): → stop_requests.push(addr) │ ││
|
│ │ │ request_stop(addr): → stop_requests.push(addr) │ ││
|
||||||
│ │ │ schedule_timer(req): → timer_requests.push(req) │ ││
|
│ │ │ post_worker_request(r): → worker_requests.push(r) │ ││
|
||||||
│ │ │ where_is(name): → name_registry.lookup(name) │ ││
|
│ │ │ extension(): → tc.extension │ ││
|
||||||
│ │ │ monitor(w, t): → monitor_registry.register(w, t) │ ││
|
|
||||||
│ │ │ join_group(a, g): → group_registry.join(g, a) │ ││
|
|
||||||
│ │ │ │ ││
|
│ │ │ │ ││
|
||||||
│ │ └────────────────────────────────────────────────────────────┘ ││
|
│ │ └────────────────────────────────────────────────────────────┘ ││
|
||||||
│ │ ││
|
│ │ ││
|
||||||
|
|
@ -242,12 +237,12 @@ Lives on `Arc<Runtime>`, shared read-only across all worker threads.
|
||||||
│ └────────────────────────────────────────────────────────────────────┘│
|
│ └────────────────────────────────────────────────────────────────────┘│
|
||||||
│ │ │
|
│ │ │
|
||||||
│ v │
|
│ v │
|
||||||
│ PHASE 5.5 --- Drain Timer Requests │
|
│ PHASE 5.5 --- Drain Worker Extension Requests │
|
||||||
│ ┌────────────────────────────────────────────────────────────────────┐│
|
│ ┌────────────────────────────────────────────────────────────────────┐│
|
||||||
│ │ ││
|
│ │ ││
|
||||||
│ │ for request in timer_requests: ││
|
│ │ for request in worker_requests: ││
|
||||||
│ │ Once { dest, msg, ticks } → timers.add_once(dest, msg, ticks) ││
|
│ │ worker_ext.handle_request(request) ││
|
||||||
│ │ Interval { dest, msg, p } → timers.add_interval(dest, msg, p) ││
|
│ │ (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 ││
|
│ │ stopping actors: call on_stop(&ctx) before removal ││
|
||||||
│ │ poisoned actors: skip on_stop (state may be corrupt) ││
|
│ │ poisoned actors: skip on_stop (state may be corrupt) ││
|
||||||
│ │ ││
|
│ │ ││
|
||||||
│ │ for each dead (addr, reason): ││
|
│ │ for each dead addr: ││
|
||||||
│ │ address_map.remove(&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 ││
|
│ │ deliver any messages sent during on_stop callbacks ││
|
||||||
│ │ ││
|
│ │ ││
|
||||||
│ │ emit Down notifications for monitored dead actors: ││
|
│ │ worker_ext.gc_dead(&dead_addrs) ││
|
||||||
│ │ for (addr, reason) in dead: ││
|
│ │ (StdExtension: removes orphaned interval timers) ││
|
||||||
│ │ 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) ││
|
|
||||||
│ │ ││
|
│ │ ││
|
||||||
│ └────────────────────────────────────────────────────────────────────┘│
|
│ └────────────────────────────────────────────────────────────────────┘│
|
||||||
│ │
|
│ │
|
||||||
|
|
@ -510,13 +502,12 @@ Who holds what:
|
||||||
│ │ addr->wid │ │ load-aware │ │ addr->Sender│ │ AtomicBool │ │
|
│ │ addr->wid │ │ load-aware │ │ addr->Sender│ │ AtomicBool │ │
|
||||||
│ └─────┬─────┘ └──────┬─────┘ └──────┬──────┘ └──────┬─────┘ │
|
│ └─────┬─────┘ └──────┬─────┘ └──────┬──────┘ └──────┬─────┘ │
|
||||||
│ │ │ │ │ │
|
│ │ │ │ │ │
|
||||||
│ ┌─────────────┐ ┌──────────────┐ ┌─────────────┐ │
|
│ ┌─ extension: Arc<dyn RuntimeExtension> ──────────────────────────┐ │
|
||||||
│ │NameRegistry │ │MonitorRegist.│ │GroupRegistry │ │
|
│ │ StdExtension holds: NameRegistry, MonitorRegistry, │ │
|
||||||
│ │ name->addr │ │ watched-> │ │ group->addrs │ │
|
│ │ GroupRegistry, WatchRegistry (accessed via downcast) │ │
|
||||||
│ │ addr->name │ │ watchers │ │ addr->groups │ │
|
│ └─────────────────────────────────┬───────────────────────────────┘ │
|
||||||
│ └─────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
|
│ │ │
|
||||||
│ │ │ │ │
|
│ ┌─────────────────────────────────┴──────────────────────────────────┐ │
|
||||||
│ ┌─────┴───────────────┴──────────────┴───────────────────────────────┐ │
|
|
||||||
│ │ TickContext (borrows all above) │ │
|
│ │ TickContext (borrows all above) │ │
|
||||||
│ └──────────────────────────┬──────────────────────────────────────────┘ │
|
│ └──────────────────────────┬──────────────────────────────────────────┘ │
|
||||||
│ │ │
|
│ │ │
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ cargo-fuzz = true
|
||||||
libfuzzer-sys = { version = "0.4", features = ["arbitrary-derive"] }
|
libfuzzer-sys = { version = "0.4", features = ["arbitrary-derive"] }
|
||||||
arbitrary = { version = "1", features = ["derive"] }
|
arbitrary = { version = "1", features = ["derive"] }
|
||||||
swactor = { path = "..", default-features = true }
|
swactor = { path = "..", default-features = true }
|
||||||
|
swactor-std = { path = "../crates/std" }
|
||||||
|
|
||||||
# Prevent this from interfering with workspaces
|
# Prevent this from interfering with workspaces
|
||||||
[workspace]
|
[workspace]
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,12 @@ use std::sync::OnceLock;
|
||||||
use arbitrary::Arbitrary;
|
use arbitrary::Arbitrary;
|
||||||
use libfuzzer_sys::fuzz_target;
|
use libfuzzer_sys::fuzz_target;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use swactor::actor::{ActorAddress, ActorInterface};
|
use swactor::actor::{ActorAddress, ActorInterface};
|
||||||
use swactor::config::RuntimeConfig;
|
use swactor::config::RuntimeConfig;
|
||||||
use swactor::runtime::{Ctx, Inbox, Runtime};
|
use swactor::runtime::{Ctx, Inbox, Runtime};
|
||||||
|
use swactor_std::{CtxTimers, StdExtension};
|
||||||
|
|
||||||
// ─── Run Logging ────────────────────────────────────────────────────────────
|
// ─── Run Logging ────────────────────────────────────────────────────────────
|
||||||
// FUZZ_LOG=1 → trace every run
|
// FUZZ_LOG=1 → trace every run
|
||||||
|
|
@ -881,7 +884,8 @@ fuzz_target!(|input: FuzzInput| {
|
||||||
num_threads: 1,
|
num_threads: 1,
|
||||||
..Default::default()
|
..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 mut state = FuzzState::new(rt, tracing);
|
||||||
|
|
||||||
let scenario_limit = input.scenarios.len().min(64);
|
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`.
|
/// Not a `Message` — intercepted in `tick_all` before reaching `handle_any`.
|
||||||
pub(crate) struct StopSignal;
|
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.
|
/// Object-safe inner trait for sending type-erased messages.
|
||||||
///
|
///
|
||||||
/// Minimal core interface: send, spawn, stop, timers, and extension access.
|
/// Minimal core interface: send, spawn, stop, and extension access.
|
||||||
/// Registry methods (naming, monitoring, groups) are provided by extension
|
/// Registry methods (naming, monitoring, groups) and timer scheduling
|
||||||
/// traits in `swactor-std`.
|
/// are provided by extension traits in `swactor-std`.
|
||||||
#[allow(private_interfaces)]
|
#[allow(private_interfaces)]
|
||||||
pub trait ContextInner {
|
pub trait ContextInner {
|
||||||
fn send_any(&self, addr: ActorAddress, msg: Box<dyn Any + Send>) -> Result<(), Error>;
|
fn send_any(&self, addr: ActorAddress, msg: Box<dyn Any + Send>) -> Result<(), Error>;
|
||||||
fn spawn_any(&self, addr: ActorAddress, actor: Box<dyn AnyActor>);
|
fn spawn_any(&self, addr: ActorAddress, actor: Box<dyn AnyActor>);
|
||||||
/// Request graceful stop for an actor. Takes effect after the current message.
|
/// Request graceful stop for an actor. Takes effect after the current message.
|
||||||
fn request_stop(&self, addr: ActorAddress);
|
fn request_stop(&self, addr: ActorAddress);
|
||||||
/// Schedule a timer (one-shot or interval).
|
/// Post a request to the per-worker extension (e.g., timer scheduling).
|
||||||
fn schedule_timer(&self, request: TimerRequest);
|
fn post_worker_request(&self, request: Box<dyn Any + Send>);
|
||||||
/// Access the runtime extension (if installed).
|
/// Access the runtime extension (if installed).
|
||||||
fn extension(&self) -> Option<&dyn crate::extension::RuntimeExtension>;
|
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()`.
|
/// Actor syscall interface — passed to `ActorInterface::handle()`.
|
||||||
|
|
@ -307,46 +275,4 @@ impl<'a> Ctx<'a> {
|
||||||
self.inner.send_any(addr, Box::new(StopSignal))
|
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::collections::{HashMap, HashSet};
|
||||||
use std::hash::{BuildHasher, Hasher};
|
use std::hash::{BuildHasher, Hasher};
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
use std::sync::{Arc, Mutex, OnceLock, RwLock};
|
use std::sync::{Arc, OnceLock, RwLock};
|
||||||
use std::thread::Thread;
|
use std::thread::Thread;
|
||||||
|
|
||||||
use crate::actor::{ActorAddress, AnyActor, Message};
|
use crate::actor::{ActorAddress, AnyActor, Message};
|
||||||
use crate::channel::Sender;
|
use crate::channel::Sender;
|
||||||
use crate::config::RuntimeConfig;
|
use crate::config::RuntimeConfig;
|
||||||
use crate::stats::WorkerStats;
|
use crate::stats::WorkerStats;
|
||||||
use crate::worker::WatchRegistry;
|
|
||||||
use crate::Error;
|
use crate::Error;
|
||||||
|
|
||||||
// ─── Identity Hasher for ActorAddress ───────────────────────────────────────
|
// ─── Identity Hasher for ActorAddress ───────────────────────────────────────
|
||||||
|
|
@ -244,7 +243,6 @@ pub(crate) struct TickContext<'a> {
|
||||||
pub(crate) stats_hook: Option<&'a dyn crate::stats::StatsHook>,
|
pub(crate) stats_hook: Option<&'a dyn crate::stats::StatsHook>,
|
||||||
/// Thread handles for waking parked workers on cross-worker sends.
|
/// Thread handles for waking parked workers on cross-worker sends.
|
||||||
pub(crate) worker_threads: &'a [OnceLock<Thread>],
|
pub(crate) worker_threads: &'a [OnceLock<Thread>],
|
||||||
pub(crate) watch_registry: Option<&'a Arc<Mutex<WatchRegistry>>>,
|
|
||||||
#[cfg(feature = "transport")]
|
#[cfg(feature = "transport")]
|
||||||
pub(crate) codec_registry: Option<&'a crate::transport::CodecRegistry>,
|
pub(crate) codec_registry: Option<&'a crate::transport::CodecRegistry>,
|
||||||
#[cfg(feature = "transport")]
|
#[cfg(feature = "transport")]
|
||||||
|
|
|
||||||
|
|
@ -25,4 +25,29 @@ pub trait RuntimeExtension: Send + Sync {
|
||||||
|
|
||||||
/// Downcast support for Ctx extension traits.
|
/// Downcast support for Ctx extension traits.
|
||||||
fn as_any(&self) -> &dyn Any;
|
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::any::Any;
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::{Arc, Mutex, OnceLock};
|
use std::sync::{Arc, OnceLock};
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use std::thread::{self, JoinHandle};
|
use std::thread::{self, JoinHandle};
|
||||||
use std::thread::Thread;
|
use std::thread::Thread;
|
||||||
use crate::Instant;
|
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};
|
use crate::channel::{Receiver, Sender};
|
||||||
// Re-export config types so existing code using `runtime::RuntimeConfig` still works
|
// Re-export config types so existing code using `runtime::RuntimeConfig` still works
|
||||||
pub use crate::config::{BackoffPolicy, MailboxOverflow, RuntimeConfig};
|
pub use crate::config::{BackoffPolicy, MailboxOverflow, RuntimeConfig};
|
||||||
|
|
@ -16,7 +16,7 @@ use crate::extension::RuntimeExtension;
|
||||||
use crate::stats::{StatsHook, WorkerStats};
|
use crate::stats::{StatsHook, WorkerStats};
|
||||||
// Re-export stats types so existing code using `runtime::*` still works
|
// Re-export stats types so existing code using `runtime::*` still works
|
||||||
pub use crate::stats::{RuntimeStats, WorkerInfo};
|
pub use crate::stats::{RuntimeStats, WorkerInfo};
|
||||||
use crate::worker::{WatchRegistry, Worker};
|
use crate::worker::Worker;
|
||||||
use crate::Error;
|
use crate::Error;
|
||||||
|
|
||||||
/// Generic message inbox for receiving messages outside of the runtime.
|
/// Generic message inbox for receiving messages outside of the runtime.
|
||||||
|
|
@ -107,7 +107,6 @@ pub struct Runtime {
|
||||||
is_running: AtomicBool,
|
is_running: AtomicBool,
|
||||||
worker_stats: Vec<Arc<WorkerStats>>,
|
worker_stats: Vec<Arc<WorkerStats>>,
|
||||||
stats_hook: Option<Arc<dyn StatsHook>>,
|
stats_hook: Option<Arc<dyn StatsHook>>,
|
||||||
watch_registry: Arc<Mutex<WatchRegistry>>,
|
|
||||||
/// Workers available for tick(). run() drains this and moves workers to threads.
|
/// Workers available for tick(). run() drains this and moves workers to threads.
|
||||||
tick_workers: RefCell<Vec<Worker>>,
|
tick_workers: RefCell<Vec<Worker>>,
|
||||||
/// Thread handles for waking parked workers. Set by workers on startup via OnceLock.
|
/// Thread handles for waking parked workers. Set by workers on startup via OnceLock.
|
||||||
|
|
@ -203,7 +202,6 @@ impl Runtime {
|
||||||
is_running: AtomicBool::new(false),
|
is_running: AtomicBool::new(false),
|
||||||
worker_stats,
|
worker_stats,
|
||||||
stats_hook: None,
|
stats_hook: None,
|
||||||
watch_registry: Arc::new(Mutex::new(WatchRegistry::new())),
|
|
||||||
tick_workers: RefCell::new(workers),
|
tick_workers: RefCell::new(workers),
|
||||||
worker_threads,
|
worker_threads,
|
||||||
created_at: Instant::now(),
|
created_at: Instant::now(),
|
||||||
|
|
@ -247,6 +245,12 @@ impl Runtime {
|
||||||
///
|
///
|
||||||
/// Must be called before `run()` or `tick()`.
|
/// Must be called before `run()` or `tick()`.
|
||||||
pub fn with_extension(mut self, ext: Arc<dyn RuntimeExtension>) -> Self {
|
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.extension = Some(ext);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
@ -310,7 +314,6 @@ impl Runtime {
|
||||||
extension: self.extension.as_deref(),
|
extension: self.extension.as_deref(),
|
||||||
stats_hook: self.stats_hook.as_deref(),
|
stats_hook: self.stats_hook.as_deref(),
|
||||||
worker_threads: &self.worker_threads,
|
worker_threads: &self.worker_threads,
|
||||||
watch_registry: Some(&self.watch_registry),
|
|
||||||
#[cfg(feature = "transport")]
|
#[cfg(feature = "transport")]
|
||||||
codec_registry: self.codec_registry.as_deref(),
|
codec_registry: self.codec_registry.as_deref(),
|
||||||
#[cfg(feature = "transport")]
|
#[cfg(feature = "transport")]
|
||||||
|
|
@ -506,35 +509,13 @@ impl ContextInner for Runtime {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn schedule_timer(&self, _request: TimerRequest) {
|
fn post_worker_request(&self, _request: Box<dyn Any + Send>) {
|
||||||
// Timers are per-worker and tick-counted; scheduling from outside
|
// Worker requests (e.g., timers) are per-worker; posting from outside
|
||||||
// a worker context (e.g., rt.spawn() callback) is not supported.
|
// a worker context (e.g., rt.spawn() callback) is not supported.
|
||||||
// Use rt.send_to() with a delay loop instead.
|
eprintln!("swactor: post_worker_request called outside worker context — ignored");
|
||||||
eprintln!("swactor: schedule_timer called outside worker context — ignored");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extension(&self) -> Option<&dyn RuntimeExtension> {
|
fn extension(&self) -> Option<&dyn RuntimeExtension> {
|
||||||
self.extension.as_deref()
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,8 @@ pub struct ActorSnapshot {
|
||||||
pub last_msg_type: Option<&'static str>,
|
pub last_msg_type: Option<&'static str>,
|
||||||
pub messages_processed: u64,
|
pub messages_processed: u64,
|
||||||
pub poisoned: bool,
|
pub poisoned: bool,
|
||||||
|
/// Per-message-type counts, sorted descending by count.
|
||||||
|
pub message_type_counts: Vec<(&'static str, u64)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Observer hook called by workers after productive ticks.
|
/// Observer hook called by workers after productive ticks.
|
||||||
|
|
@ -150,6 +152,9 @@ pub struct ActorInfo {
|
||||||
/// Whether the actor has panicked and is no longer processing messages.
|
/// Whether the actor has panicked and is no longer processing messages.
|
||||||
#[cfg_attr(feature = "serde", serde(default))]
|
#[cfg_attr(feature = "serde", serde(default))]
|
||||||
pub poisoned: bool,
|
pub poisoned: bool,
|
||||||
|
/// Per-message-type counts, sorted descending by count. Top 32 types.
|
||||||
|
#[cfg_attr(feature = "serde", serde(default))]
|
||||||
|
pub message_type_counts: Vec<(String, u64)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Snapshot of overall runtime state.
|
/// Snapshot of overall runtime state.
|
||||||
|
|
|
||||||
470
src/worker.rs
470
src/worker.rs
|
|
@ -1,199 +1,40 @@
|
||||||
use std::any::Any;
|
use std::any::Any;
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::collections::{HashMap, HashSet, VecDeque};
|
use std::collections::{HashMap, VecDeque};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use crate::Instant;
|
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::channel::Receiver;
|
||||||
use crate::config::MailboxOverflow;
|
use crate::config::MailboxOverflow;
|
||||||
use crate::delivery::{AddrBuildHasher, AddrMap, Envelope, TickContext, WorkerId};
|
use crate::delivery::{AddrBuildHasher, AddrMap, Envelope, TickContext, WorkerId};
|
||||||
use crate::stats::{ActorSnapshot, TickTiming, WorkerStats};
|
use crate::stats::{ActorSnapshot, TickTiming, WorkerStats};
|
||||||
use crate::Error;
|
use crate::Error;
|
||||||
|
|
||||||
// ─── Per-Worker Timer Wheel ─────────────────────────────────────────────────
|
use crate::extension::WorkerExtension;
|
||||||
|
|
||||||
struct OnceTimer {
|
/// Route a message: try local pool first, then address_map for cross-worker,
|
||||||
fire_at: u64,
|
/// then inbox_registry for external receivers.
|
||||||
|
fn route_to_pool_or_remote(
|
||||||
|
pool: &mut ActorPool,
|
||||||
|
tc: &TickContext,
|
||||||
dest: ActorAddress,
|
dest: ActorAddress,
|
||||||
msg: Box<dyn Any + Send>,
|
msg: Box<dyn Any + Send>,
|
||||||
}
|
) {
|
||||||
|
if pool.contains(&dest) {
|
||||||
struct IntervalTimer {
|
pool.deliver(&dest, msg);
|
||||||
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));
|
|
||||||
} else {
|
} 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>,
|
stats: Arc<WorkerStats>,
|
||||||
/// Reusable scratch buffer for building per-actor snapshots.
|
/// Reusable scratch buffer for building per-actor snapshots.
|
||||||
snapshot_buf: Vec<ActorSnapshot>,
|
snapshot_buf: Vec<ActorSnapshot>,
|
||||||
/// Per-worker tick-counting timer wheel.
|
/// Per-worker extension (e.g., timer wheel). Created by RuntimeExtension factory.
|
||||||
timers: TimerWheel,
|
pub(crate) worker_ext: Option<Box<dyn WorkerExtension>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Worker {
|
impl Worker {
|
||||||
|
|
@ -228,19 +69,15 @@ impl Worker {
|
||||||
spawn_rx,
|
spawn_rx,
|
||||||
stats,
|
stats,
|
||||||
snapshot_buf: Vec::new(),
|
snapshot_buf: Vec::new(),
|
||||||
timers: TimerWheel::new(),
|
worker_ext: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run one iteration of the worker loop. Returns `true` if any work was done.
|
/// Run one iteration of the worker loop. Returns `true` if any work was done.
|
||||||
pub(crate) fn tick_once(&mut self, tc: &TickContext) -> bool {
|
/// Drain the spawn queue, inserting new actors into the pool.
|
||||||
#[cfg(feature = "tracing")]
|
/// Used in phases 1 and 4 of tick_once.
|
||||||
let _span = tracing::trace_span!("worker.tick", worker_id = self.id.0).entered();
|
fn drain_spawns(&mut self) -> bool {
|
||||||
|
|
||||||
let mut did_work = false;
|
let mut did_work = false;
|
||||||
let t0 = Instant::now();
|
|
||||||
|
|
||||||
// 1. Drain spawn queue → add actors to pool
|
|
||||||
#[cfg(feature = "tracing")]
|
#[cfg(feature = "tracing")]
|
||||||
let mut spawn_count: usize = 0;
|
let mut spawn_count: usize = 0;
|
||||||
while let Some((addr, actor)) = self.spawn_rx.try_recv() {
|
while let Some((addr, actor)) = self.spawn_rx.try_recv() {
|
||||||
|
|
@ -253,6 +90,68 @@ impl Worker {
|
||||||
if spawn_count > 0 {
|
if spawn_count > 0 {
|
||||||
tracing::debug!(worker_id = self.id.0, count = spawn_count, "worker.spawns_drained");
|
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();
|
let t1 = Instant::now();
|
||||||
|
|
||||||
// 2. Drain transfer queue → deliver envelopes to actors
|
// 2. Drain transfer queue → deliver envelopes to actors
|
||||||
|
|
@ -264,24 +163,12 @@ impl Worker {
|
||||||
}
|
}
|
||||||
let t2 = Instant::now();
|
let t2 = Instant::now();
|
||||||
|
|
||||||
// 2.5. Fire due timers → deliver to mailboxes before tick_all
|
// 2.5. Fire per-worker extension (e.g., timers) → deliver before tick_all
|
||||||
let timer_msgs = self.timers.fire();
|
let ext_msgs: Vec<_> = self.worker_ext.as_mut()
|
||||||
for (dest, msg) in timer_msgs {
|
.map(|ext| ext.on_tick())
|
||||||
if self.pool.contains(&dest) {
|
.unwrap_or_default();
|
||||||
// Same-worker: deliver directly to actor's mailbox
|
for (dest, msg) in ext_msgs {
|
||||||
self.pool.deliver(&dest, msg);
|
route_to_pool_or_remote(&mut self.pool, tc, 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
did_work = true;
|
did_work = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -289,20 +176,19 @@ impl Worker {
|
||||||
let pending_local: RefCell<Vec<(ActorAddress, Box<dyn Any + Send>)>> =
|
let pending_local: RefCell<Vec<(ActorAddress, Box<dyn Any + Send>)>> =
|
||||||
RefCell::new(Vec::new());
|
RefCell::new(Vec::new());
|
||||||
let stop_requests: RefCell<Vec<ActorAddress>> = 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 processed;
|
||||||
let deaths;
|
|
||||||
{
|
{
|
||||||
let worker_ctx = WorkerContext {
|
let worker_ctx = WorkerContext {
|
||||||
worker_id: self.id,
|
worker_id: self.id,
|
||||||
tc,
|
tc,
|
||||||
pending_local: &pending_local,
|
pending_local: &pending_local,
|
||||||
stop_requests: &stop_requests,
|
stop_requests: &stop_requests,
|
||||||
timer_requests: &timer_requests,
|
worker_requests: &worker_requests,
|
||||||
stats: &self.stats,
|
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 {
|
if processed > 0 {
|
||||||
did_work = true;
|
did_work = true;
|
||||||
}
|
}
|
||||||
|
|
@ -320,10 +206,7 @@ impl Worker {
|
||||||
|
|
||||||
// 4. Drain spawn queue again — actors spawned during step 3
|
// 4. Drain spawn queue again — actors spawned during step 3
|
||||||
// must be in the pool before pending_local delivery.
|
// must be in the pool before pending_local delivery.
|
||||||
while let Some((addr, actor)) = self.spawn_rx.try_recv() {
|
did_work |= self.drain_spawns();
|
||||||
self.pool.insert(addr, actor);
|
|
||||||
did_work = true;
|
|
||||||
}
|
|
||||||
let t4 = Instant::now();
|
let t4 = Instant::now();
|
||||||
|
|
||||||
// 5. Drain pending_local buffer → deliver to local actors
|
// 5. Drain pending_local buffer → deliver to local actors
|
||||||
|
|
@ -335,48 +218,10 @@ impl Worker {
|
||||||
self.pool.deliver(&addr, msg);
|
self.pool.deliver(&addr, msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5.5. Process timer requests from handlers
|
// 5.5. Process worker extension requests from handlers (e.g., timer scheduling)
|
||||||
for request in timer_requests.into_inner() {
|
if let Some(ext) = &mut self.worker_ext {
|
||||||
match request {
|
for request in worker_requests.into_inner() {
|
||||||
TimerRequest::Once { dest, msg, ticks } => {
|
ext.handle_request(request);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -427,67 +272,7 @@ impl Worker {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Clean up poisoned and stopping actors
|
// 7. Clean up poisoned and stopping actors
|
||||||
// on_stop() may send messages, so provide a fresh pending_local buffer.
|
did_work |= self.cleanup_dead_actors(tc);
|
||||||
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
|
did_work
|
||||||
}
|
}
|
||||||
|
|
@ -531,7 +316,7 @@ struct WorkerContext<'a> {
|
||||||
tc: &'a TickContext<'a>,
|
tc: &'a TickContext<'a>,
|
||||||
pending_local: &'a RefCell<Vec<(ActorAddress, Box<dyn Any + Send>)>>,
|
pending_local: &'a RefCell<Vec<(ActorAddress, Box<dyn Any + Send>)>>,
|
||||||
stop_requests: &'a RefCell<Vec<ActorAddress>>,
|
stop_requests: &'a RefCell<Vec<ActorAddress>>,
|
||||||
timer_requests: &'a RefCell<Vec<TimerRequest>>,
|
worker_requests: &'a RefCell<Vec<Box<dyn Any + Send>>>,
|
||||||
stats: &'a WorkerStats,
|
stats: &'a WorkerStats,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -568,36 +353,13 @@ impl ContextInner for WorkerContext<'_> {
|
||||||
self.stop_requests.borrow_mut().push(addr);
|
self.stop_requests.borrow_mut().push(addr);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn schedule_timer(&self, request: TimerRequest) {
|
fn post_worker_request(&self, request: Box<dyn Any + Send>) {
|
||||||
self.timer_requests.borrow_mut().push(request);
|
self.worker_requests.borrow_mut().push(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extension(&self) -> Option<&dyn crate::extension::RuntimeExtension> {
|
fn extension(&self) -> Option<&dyn crate::extension::RuntimeExtension> {
|
||||||
self.tc.extension
|
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 {
|
struct ActorSlot {
|
||||||
|
|
@ -610,6 +372,8 @@ struct ActorSlot {
|
||||||
started: bool,
|
started: bool,
|
||||||
last_msg_type: Option<&'static str>,
|
last_msg_type: Option<&'static str>,
|
||||||
messages_processed: u64,
|
messages_processed: u64,
|
||||||
|
/// Per-message-type counters (bounded to 32 entries).
|
||||||
|
msg_type_counts: HashMap<&'static str, u64>,
|
||||||
/// Per-actor mailbox capacity. 0 = unbounded.
|
/// Per-actor mailbox capacity. 0 = unbounded.
|
||||||
mailbox_capacity: usize,
|
mailbox_capacity: usize,
|
||||||
overflow_policy: MailboxOverflow,
|
overflow_policy: MailboxOverflow,
|
||||||
|
|
@ -645,6 +409,7 @@ impl ActorPool {
|
||||||
started: false,
|
started: false,
|
||||||
last_msg_type: None,
|
last_msg_type: None,
|
||||||
messages_processed: 0,
|
messages_processed: 0,
|
||||||
|
msg_type_counts: HashMap::new(),
|
||||||
mailbox_capacity: self.default_mailbox_capacity,
|
mailbox_capacity: self.default_mailbox_capacity,
|
||||||
overflow_policy: self.default_overflow_policy,
|
overflow_policy: self.default_overflow_policy,
|
||||||
});
|
});
|
||||||
|
|
@ -678,7 +443,7 @@ impl ActorPool {
|
||||||
std::mem::replace(&mut self.drops_this_tick, 0)
|
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).
|
/// Each actor processes up to `budget` messages per tick (0 = unlimited).
|
||||||
/// This prevents a single hot actor from starving others on the same worker.
|
/// This prevents a single hot actor from starving others on the same worker.
|
||||||
|
|
@ -688,9 +453,8 @@ impl ActorPool {
|
||||||
stats: &WorkerStats,
|
stats: &WorkerStats,
|
||||||
budget: usize,
|
budget: usize,
|
||||||
stop_requests: &RefCell<Vec<ActorAddress>>,
|
stop_requests: &RefCell<Vec<ActorAddress>>,
|
||||||
) -> (usize, Vec<(ActorAddress, ExitReason)>) {
|
) -> usize {
|
||||||
let mut count = 0;
|
let mut count = 0;
|
||||||
let mut deaths = Vec::new();
|
|
||||||
for (&addr, slot) in self.actors.iter_mut() {
|
for (&addr, slot) in self.actors.iter_mut() {
|
||||||
if slot.poisoned || slot.stopping {
|
if slot.poisoned || slot.stopping {
|
||||||
// Discard all messages for poisoned/stopping actors
|
// Discard all messages for poisoned/stopping actors
|
||||||
|
|
@ -698,6 +462,9 @@ impl ActorPool {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "tracing")]
|
||||||
|
let _actor_span = tracing::trace_span!("actor.tick", actor_addr = %addr).entered();
|
||||||
|
|
||||||
let ctx = Ctx::new(inner, addr);
|
let ctx = Ctx::new(inner, addr);
|
||||||
|
|
||||||
// Call on_start once, before first message
|
// Call on_start once, before first message
|
||||||
|
|
@ -735,7 +502,6 @@ impl ActorPool {
|
||||||
slot.stopping = true;
|
slot.stopping = true;
|
||||||
stats.stops.fetch_add(1, Ordering::Relaxed);
|
stats.stops.fetch_add(1, Ordering::Relaxed);
|
||||||
slot.mailbox.clear();
|
slot.mailbox.clear();
|
||||||
deaths.push((addr, ExitReason::Stopped));
|
|
||||||
#[cfg(feature = "tracing")]
|
#[cfg(feature = "tracing")]
|
||||||
tracing::info!(actor_addr = %addr, "actor.stop_requested");
|
tracing::info!(actor_addr = %addr, "actor.stop_requested");
|
||||||
break;
|
break;
|
||||||
|
|
@ -756,12 +522,15 @@ impl ActorPool {
|
||||||
tracing::error!(actor_addr = %addr, "actor.panicked");
|
tracing::error!(actor_addr = %addr, "actor.panicked");
|
||||||
slot.poisoned = true;
|
slot.poisoned = true;
|
||||||
slot.mailbox.clear();
|
slot.mailbox.clear();
|
||||||
deaths.push((addr, ExitReason::Panicked));
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Ok(Some(type_name)) => {
|
Ok(Some(type_name)) => {
|
||||||
slot.last_msg_type = Some(type_name);
|
slot.last_msg_type = Some(type_name);
|
||||||
slot.messages_processed += 1;
|
slot.messages_processed += 1;
|
||||||
|
// Track per-type counts (bounded to 32 distinct types)
|
||||||
|
if slot.msg_type_counts.len() < 32 || slot.msg_type_counts.contains_key(type_name) {
|
||||||
|
*slot.msg_type_counts.entry(type_name).or_insert(0) += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
count += 1;
|
count += 1;
|
||||||
|
|
@ -775,7 +544,6 @@ impl ActorPool {
|
||||||
slot.stopping = true;
|
slot.stopping = true;
|
||||||
stats.stops.fetch_add(1, Ordering::Relaxed);
|
stats.stops.fetch_add(1, Ordering::Relaxed);
|
||||||
slot.mailbox.clear();
|
slot.mailbox.clear();
|
||||||
deaths.push((addr, ExitReason::Stopped));
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -785,7 +553,7 @@ impl ActorPool {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(count, deaths)
|
count
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn len(&self) -> usize {
|
pub fn len(&self) -> usize {
|
||||||
|
|
@ -834,12 +602,16 @@ impl ActorPool {
|
||||||
pub fn mailbox_depths_into(&self, out: &mut Vec<ActorSnapshot>) {
|
pub fn mailbox_depths_into(&self, out: &mut Vec<ActorSnapshot>) {
|
||||||
out.clear();
|
out.clear();
|
||||||
out.extend(self.actors.iter().map(|(&addr, slot)| {
|
out.extend(self.actors.iter().map(|(&addr, slot)| {
|
||||||
|
let mut type_counts: Vec<(&'static str, u64)> =
|
||||||
|
slot.msg_type_counts.iter().map(|(&k, &v)| (k, v)).collect();
|
||||||
|
type_counts.sort_by(|a, b| b.1.cmp(&a.1));
|
||||||
ActorSnapshot {
|
ActorSnapshot {
|
||||||
address: addr,
|
address: addr,
|
||||||
mailbox_depth: slot.mailbox.len(),
|
mailbox_depth: slot.mailbox.len(),
|
||||||
last_msg_type: slot.last_msg_type,
|
last_msg_type: slot.last_msg_type,
|
||||||
messages_processed: slot.messages_processed,
|
messages_processed: slot.messages_processed,
|
||||||
poisoned: slot.poisoned,
|
poisoned: slot.poisoned,
|
||||||
|
message_type_counts: type_counts,
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
790
tests/actor_lifecycle.rs
Normal file
790
tests/actor_lifecycle.rs
Normal file
|
|
@ -0,0 +1,790 @@
|
||||||
|
//! Actor Lifecycle Tests — birth, life, death of individual actors.
|
||||||
|
//!
|
||||||
|
//! Covers: spawning, on_start, parent-child delegation, graceful stop,
|
||||||
|
//! panic isolation, dead actor cleanup, watching (ActorExited), and
|
||||||
|
//! monitoring (Down notifications).
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
use common::*;
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
// ── Local actors ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops itself after processing `stop_after` 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends a farewell Pong 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Panics in on_start.
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns a DoubleActor child, sends it work, then panics.
|
||||||
|
struct SpawnThenPanicActor;
|
||||||
|
|
||||||
|
impl ActorInterface for SpawnThenPanicActor {
|
||||||
|
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 });
|
||||||
|
panic!("intentional panic after spawn+send");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends a Pong reply, then panics.
|
||||||
|
struct SendThenPanicActor;
|
||||||
|
|
||||||
|
impl ActorInterface for SendThenPanicActor {
|
||||||
|
type Incoming = Ping;
|
||||||
|
type Response = Pong;
|
||||||
|
fn handle(&mut self, ctx: &Ctx, msg: Ping) {
|
||||||
|
let _ = ctx.send(msg.reply_to, Pong);
|
||||||
|
panic!("intentional panic after send");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Processes `remaining_good` messages then panics.
|
||||||
|
struct PanicAfterNActor {
|
||||||
|
remaining_good: usize,
|
||||||
|
counter: Arc<AtomicUsize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActorInterface for PanicAfterNActor {
|
||||||
|
type Incoming = Ping;
|
||||||
|
type Response = ();
|
||||||
|
fn handle(&mut self, _ctx: &Ctx, _msg: Ping) {
|
||||||
|
if self.remaining_good == 0 {
|
||||||
|
panic!("intentional delayed panic");
|
||||||
|
}
|
||||||
|
self.remaining_good -= 1;
|
||||||
|
self.counter.fetch_add(1, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops on a trigger message.
|
||||||
|
struct StopOnTrigger(Arc<AtomicUsize>);
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct Trigger(bool);
|
||||||
|
|
||||||
|
impl ActorInterface for StopOnTrigger {
|
||||||
|
type Incoming = Trigger;
|
||||||
|
type Response = ();
|
||||||
|
fn handle(&mut self, ctx: &Ctx, msg: Trigger) {
|
||||||
|
self.0.fetch_add(1, Ordering::Relaxed);
|
||||||
|
if msg.0 {
|
||||||
|
ctx.stop_self();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Watches targets and counts exit notifications via on_actor_exit.
|
||||||
|
struct ExitWatcher {
|
||||||
|
exit_count: Arc<AtomicUsize>,
|
||||||
|
last_reason: Arc<std::sync::Mutex<Option<ExitReason>>>,
|
||||||
|
last_addr: Arc<std::sync::Mutex<Option<ActorAddress>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
enum WatcherCmd {
|
||||||
|
WatchThis(ActorAddress),
|
||||||
|
UnwatchThis(ActorAddress),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActorInterface for ExitWatcher {
|
||||||
|
type Incoming = WatcherCmd;
|
||||||
|
type Response = ();
|
||||||
|
fn handle(&mut self, ctx: &Ctx, msg: WatcherCmd) {
|
||||||
|
match msg {
|
||||||
|
WatcherCmd::WatchThis(target) => ctx.watch(target),
|
||||||
|
WatcherCmd::UnwatchThis(target) => ctx.unwatch(target),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn on_actor_exit(&mut self, _ctx: &Ctx, exited: ActorExited) {
|
||||||
|
self.exit_count.fetch_add(1, Ordering::SeqCst);
|
||||||
|
*self.last_reason.lock().unwrap() = Some(exited.reason);
|
||||||
|
*self.last_addr.lock().unwrap() = Some(exited.addr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WatcherState {
|
||||||
|
exit_count: Arc<AtomicUsize>,
|
||||||
|
last_reason: Arc<std::sync::Mutex<Option<ExitReason>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WatcherState {
|
||||||
|
fn count(&self) -> usize {
|
||||||
|
self.exit_count.load(Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
fn last_reason(&self) -> Option<ExitReason> {
|
||||||
|
self.last_reason.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new_exit_watcher() -> (ExitWatcher, WatcherState) {
|
||||||
|
let exit_count = Arc::new(AtomicUsize::new(0));
|
||||||
|
let last_reason = Arc::new(std::sync::Mutex::new(None));
|
||||||
|
let last_addr = Arc::new(std::sync::Mutex::new(None));
|
||||||
|
let state = WatcherState {
|
||||||
|
exit_count: exit_count.clone(),
|
||||||
|
last_reason: last_reason.clone(),
|
||||||
|
};
|
||||||
|
(
|
||||||
|
ExitWatcher { exit_count, last_reason, last_addr },
|
||||||
|
state,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Monitors a target and forwards Down to a reply address.
|
||||||
|
struct MonitorWatcherActor {
|
||||||
|
watch_target: ActorAddress,
|
||||||
|
reply_to: ActorAddress,
|
||||||
|
mref: Option<MonitorRef>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActorInterface for MonitorWatcherActor {
|
||||||
|
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) {
|
||||||
|
ctx.send(self.reply_to, msg).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Demonitors on Ping.
|
||||||
|
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) {
|
||||||
|
if let Some(mref) = self.mref.take() {
|
||||||
|
ctx.demonitor(mref);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A silent actor that does nothing (target for watching tests).
|
||||||
|
struct Sleeper;
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct Noop;
|
||||||
|
|
||||||
|
impl ActorInterface for Sleeper {
|
||||||
|
type Incoming = Noop;
|
||||||
|
type Response = ();
|
||||||
|
fn handle(&mut self, _ctx: &Ctx, _msg: Noop) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Tests
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// Actors are spawned, on_start fires exactly once per instance before any
|
||||||
|
/// message, then state accumulates across messages.
|
||||||
|
#[test]
|
||||||
|
fn actor_from_birth_to_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();
|
||||||
|
|
||||||
|
// Spawn one tracked actor + 4 more sharing the same counters
|
||||||
|
let addr = rt.spawn(LifecycleActor {
|
||||||
|
started: started.clone(),
|
||||||
|
stopped: stopped.clone(),
|
||||||
|
handled: handled.clone(),
|
||||||
|
}).unwrap();
|
||||||
|
for _ in 0..4 {
|
||||||
|
rt.spawn(LifecycleActor {
|
||||||
|
started: started.clone(),
|
||||||
|
stopped: stopped.clone(),
|
||||||
|
handled: handled.clone(),
|
||||||
|
}).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// First tick: all 5 on_start fire, no messages processed yet
|
||||||
|
rt.tick();
|
||||||
|
assert_eq!(started.load(Ordering::Relaxed), 5, "on_start per instance");
|
||||||
|
assert_eq!(handled.load(Ordering::Relaxed), 0, "no messages before first send");
|
||||||
|
|
||||||
|
// Send 3 Increments to a CounterActor to verify state accumulation
|
||||||
|
let counter_addr = rt.spawn(CounterActor { count: 0 }).unwrap();
|
||||||
|
let count_inbox = rt.new_inbox::<Count>().unwrap();
|
||||||
|
for _ in 0..3 {
|
||||||
|
rt.send_to(counter_addr, Increment { reply_to: *count_inbox.addr() }).unwrap();
|
||||||
|
}
|
||||||
|
let replies = tick_and_drain(&rt, &count_inbox, 10);
|
||||||
|
assert_eq!(replies, vec![Count(1), Count(2), Count(3)], "state accumulates");
|
||||||
|
|
||||||
|
// on_start must not fire again on subsequent ticks
|
||||||
|
rt.tick();
|
||||||
|
rt.tick();
|
||||||
|
assert_eq!(started.load(Ordering::Relaxed), 5, "on_start not repeated");
|
||||||
|
|
||||||
|
// Verify the first actor still responds normally
|
||||||
|
rt.send_to(addr, Ping { reply_to: *inbox.addr() }).unwrap();
|
||||||
|
let reply = tick_until_recv(&rt, &inbox, 10);
|
||||||
|
assert!(reply.is_some(), "actor handles messages after on_start");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delegation chains: parent spawns child, child spawns grandchild, fan-out
|
||||||
|
/// distributes work. Spawn+send interleaving in a single handler works.
|
||||||
|
#[test]
|
||||||
|
fn parent_child_delegation_and_spawn_chains() {
|
||||||
|
let rt = std_runtime(RuntimeConfig {
|
||||||
|
max_actors: 2000,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act 1: DelegatorActor spawns child, forwards value 7 → Done(14)
|
||||||
|
let delegator = rt.spawn(DelegatorActor).unwrap();
|
||||||
|
let inbox = rt.new_inbox::<Done>().unwrap();
|
||||||
|
rt.send_to(delegator, Forward { value: 7, reply_to: *inbox.addr() }).unwrap();
|
||||||
|
let reply = tick_until_recv(&rt, &inbox, 20);
|
||||||
|
assert_eq!(reply, Some(Done(14)), "delegator child doubles value");
|
||||||
|
|
||||||
|
// Act 2: Chain of depth 20
|
||||||
|
let chain = rt.spawn(ChainActor).unwrap();
|
||||||
|
rt.send_to(chain, ChainMsg { remaining: 20, depth: 0, reply_to: *inbox.addr() }).unwrap();
|
||||||
|
let reply = tick_until_recv(&rt, &inbox, 200);
|
||||||
|
assert_eq!(reply, Some(Done(20)), "chain reaches depth 20");
|
||||||
|
|
||||||
|
// Act 3: Fan-out to 20 children
|
||||||
|
let fan = rt.spawn(FanOutActor).unwrap();
|
||||||
|
rt.send_to(fan, FanOut { count: 20, reply_to: *inbox.addr() }).unwrap();
|
||||||
|
let replies = tick_and_drain(&rt, &inbox, 50);
|
||||||
|
assert_eq!(replies.len(), 20, "all 20 fan-out children reply");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The full graceful-stop story: self-stop with on_stop, farewell messages,
|
||||||
|
/// external stop ordering vs pending messages, mid-mailbox stop trigger.
|
||||||
|
#[test]
|
||||||
|
fn graceful_stop_lifecycle() {
|
||||||
|
// --- Part A: SelfStopActor ---
|
||||||
|
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() });
|
||||||
|
}
|
||||||
|
tick_n(&rt, 10);
|
||||||
|
|
||||||
|
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 self-stop");
|
||||||
|
assert_eq!(stopped.load(Ordering::Relaxed), 1, "on_stop fired");
|
||||||
|
assert!(rt.send_to(addr, Forward { value: 99, reply_to: *inbox.addr() }).is_err(),
|
||||||
|
"send to stopped actor fails");
|
||||||
|
|
||||||
|
// --- Part B: FarewellActor sends farewell in on_stop ---
|
||||||
|
let rt = std_runtime(RuntimeConfig::default());
|
||||||
|
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||||
|
let addr = rt.spawn(FarewellActor { farewell_to: *inbox.addr() }).unwrap();
|
||||||
|
rt.tick();
|
||||||
|
rt.stop_actor(addr).unwrap();
|
||||||
|
tick_n(&rt, 5);
|
||||||
|
assert_eq!(inbox.try_recv(), Some(Pong), "farewell message delivered from on_stop");
|
||||||
|
|
||||||
|
// --- Part C: External stop after pending messages ---
|
||||||
|
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: Arc::new(AtomicUsize::new(0)),
|
||||||
|
stopped: stopped.clone(),
|
||||||
|
handled: handled.clone(),
|
||||||
|
}).unwrap();
|
||||||
|
for _ in 0..10 {
|
||||||
|
let _ = rt.send_to(addr, Ping { reply_to: *inbox.addr() });
|
||||||
|
}
|
||||||
|
rt.stop_actor(addr).unwrap();
|
||||||
|
tick_n(&rt, 10);
|
||||||
|
assert_eq!(handled.load(Ordering::Relaxed), 10, "all pending messages processed before stop");
|
||||||
|
assert_eq!(stopped.load(Ordering::Relaxed), 1, "on_stop fires after messages");
|
||||||
|
|
||||||
|
// --- Part D: External stop before messages → 0 processed ---
|
||||||
|
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: Arc::new(AtomicUsize::new(0)),
|
||||||
|
stopped: Arc::new(AtomicUsize::new(0)),
|
||||||
|
handled: handled.clone(),
|
||||||
|
}).unwrap();
|
||||||
|
rt.tick(); // on_start
|
||||||
|
rt.stop_actor(addr).unwrap();
|
||||||
|
for _ in 0..5 {
|
||||||
|
let _ = rt.send_to(addr, Ping { reply_to: *inbox.addr() });
|
||||||
|
}
|
||||||
|
tick_n(&rt, 10);
|
||||||
|
assert_eq!(handled.load(Ordering::Relaxed), 0, "stop before messages prevents processing");
|
||||||
|
|
||||||
|
// --- Part E: Mid-mailbox stop trigger ---
|
||||||
|
let processed = Arc::new(AtomicUsize::new(0));
|
||||||
|
let rt = std_runtime(RuntimeConfig::default());
|
||||||
|
let addr = rt.spawn(StopOnTrigger(processed.clone())).unwrap();
|
||||||
|
rt.tick();
|
||||||
|
rt.send_to(addr, Trigger(false)).unwrap();
|
||||||
|
rt.send_to(addr, Trigger(false)).unwrap();
|
||||||
|
rt.send_to(addr, Trigger(true)).unwrap(); // stop trigger
|
||||||
|
rt.send_to(addr, Trigger(false)).unwrap();
|
||||||
|
rt.send_to(addr, Trigger(false)).unwrap();
|
||||||
|
tick_n(&rt, 5);
|
||||||
|
assert_eq!(processed.load(Ordering::Relaxed), 3,
|
||||||
|
"only messages up to and including stop trigger processed");
|
||||||
|
assert!(rt.send_to(addr, Trigger(false)).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Panics are caught: healthy siblings survive, panicked actors are poisoned
|
||||||
|
/// and cleaned from stats/address map, mid-batch panic discards remaining,
|
||||||
|
/// child spawned before parent panic survives, message sent before panic is
|
||||||
|
/// delivered, bulk cleanup, on_start panic also poisons.
|
||||||
|
#[test]
|
||||||
|
fn panic_isolation_and_cleanup() {
|
||||||
|
let rt = std_runtime(RuntimeConfig::default());
|
||||||
|
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||||
|
let count_inbox = rt.new_inbox::<Count>().unwrap();
|
||||||
|
|
||||||
|
// Spawn a healthy counter, a PanicActor, and a PanicOnStartActor
|
||||||
|
let good = rt.spawn(CounterActor { count: 0 }).unwrap();
|
||||||
|
let bad = rt.spawn(PanicActor).unwrap();
|
||||||
|
let bad_start_handled = Arc::new(AtomicUsize::new(0));
|
||||||
|
let bad_start = rt.spawn(PanicOnStartActor { handled: bad_start_handled.clone() }).unwrap();
|
||||||
|
|
||||||
|
// Trigger panics
|
||||||
|
rt.send_to(bad, PanicMsg).unwrap();
|
||||||
|
let _ = rt.send_to(bad_start, Ping { reply_to: *inbox.addr() });
|
||||||
|
tick_n(&rt, 10);
|
||||||
|
|
||||||
|
// Healthy actor still works
|
||||||
|
rt.send_to(good, Increment { reply_to: *count_inbox.addr() }).unwrap();
|
||||||
|
rt.send_to(good, Increment { reply_to: *count_inbox.addr() }).unwrap();
|
||||||
|
let replies = tick_and_drain(&rt, &count_inbox, 10);
|
||||||
|
assert_eq!(replies, vec![Count(1), Count(2)], "healthy actor unaffected by peer panics");
|
||||||
|
|
||||||
|
// Poisoned actors are cleaned from address map
|
||||||
|
assert!(rt.send_to(bad, PanicMsg).is_err(), "send to cleaned-up actor fails");
|
||||||
|
assert_eq!(bad_start_handled.load(Ordering::Relaxed), 0, "on_start panic prevents messages");
|
||||||
|
|
||||||
|
// Stats track panics vs stops separately
|
||||||
|
let stats = rt.stats();
|
||||||
|
let panics: u64 = stats.workers.iter().map(|w| w.panics).sum();
|
||||||
|
assert!(panics >= 2, "at least 2 panics recorded (PanicActor + PanicOnStartActor)");
|
||||||
|
|
||||||
|
// --- Mid-batch panic discards remaining ---
|
||||||
|
let counter = Arc::new(AtomicUsize::new(0));
|
||||||
|
let rt = std_runtime(RuntimeConfig::default());
|
||||||
|
let dummy = rt.new_inbox::<Pong>().unwrap();
|
||||||
|
let addr = rt.spawn(PanicAfterNActor { remaining_good: 2, counter: counter.clone() }).unwrap();
|
||||||
|
for _ in 0..5 {
|
||||||
|
rt.send_to(addr, Ping { reply_to: *dummy.addr() }).unwrap();
|
||||||
|
}
|
||||||
|
tick_n(&rt, 20);
|
||||||
|
assert_eq!(counter.load(Ordering::SeqCst), 2, "only messages before panic processed");
|
||||||
|
|
||||||
|
// --- Child spawned before parent panic survives ---
|
||||||
|
let rt = std_runtime(RuntimeConfig::default());
|
||||||
|
let inbox = rt.new_inbox::<Done>().unwrap();
|
||||||
|
let parent = rt.spawn(SpawnThenPanicActor).unwrap();
|
||||||
|
rt.send_to(parent, Forward { value: 5, reply_to: *inbox.addr() }).unwrap();
|
||||||
|
let reply = tick_until_recv(&rt, &inbox, 30);
|
||||||
|
assert_eq!(reply, Some(Done(10)), "child survives parent panic");
|
||||||
|
|
||||||
|
// --- Message sent before panic is delivered ---
|
||||||
|
let rt = std_runtime(RuntimeConfig::default());
|
||||||
|
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||||
|
let addr = rt.spawn(SendThenPanicActor).unwrap();
|
||||||
|
rt.send_to(addr, Ping { reply_to: *inbox.addr() }).unwrap();
|
||||||
|
let reply = tick_until_recv(&rt, &inbox, 20);
|
||||||
|
assert!(reply.is_some(), "message sent before panic still delivered");
|
||||||
|
|
||||||
|
// --- Bulk cleanup: 20 panicking actors all cleaned ---
|
||||||
|
let rt = std_runtime(RuntimeConfig::default());
|
||||||
|
let mut addrs = Vec::new();
|
||||||
|
for _ in 0..20 {
|
||||||
|
addrs.push(rt.spawn(PanicActor).unwrap());
|
||||||
|
}
|
||||||
|
for &addr in &addrs {
|
||||||
|
let _ = rt.send_to(addr, PanicMsg);
|
||||||
|
}
|
||||||
|
tick_n(&rt, 10);
|
||||||
|
let stats = rt.stats();
|
||||||
|
assert_eq!(stats.workers[0].num_actors, 0, "all poisoned actors cleaned up");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Watch API contract: watchers are notified on death, unwatch cancels,
|
||||||
|
/// double-watch is idempotent, multiple watchers all notified,
|
||||||
|
/// runtime-level watch works.
|
||||||
|
#[test]
|
||||||
|
fn watch_notification_contract() {
|
||||||
|
let rt = std_runtime(RuntimeConfig::default());
|
||||||
|
|
||||||
|
// Spawn target + 3 watchers + 1 that unwatches
|
||||||
|
let target = rt.spawn(PanicActor).unwrap();
|
||||||
|
let (w1, s1) = new_exit_watcher();
|
||||||
|
let (w2, s2) = new_exit_watcher();
|
||||||
|
let (w3, s3) = new_exit_watcher();
|
||||||
|
let (w4, s4) = new_exit_watcher(); // will unwatch
|
||||||
|
|
||||||
|
let w1_addr = rt.spawn(w1).unwrap();
|
||||||
|
let w2_addr = rt.spawn(w2).unwrap();
|
||||||
|
let w3_addr = rt.spawn(w3).unwrap();
|
||||||
|
let w4_addr = rt.spawn(w4).unwrap();
|
||||||
|
|
||||||
|
// All watch the target
|
||||||
|
rt.send_to(w1_addr, WatcherCmd::WatchThis(target)).unwrap();
|
||||||
|
rt.send_to(w2_addr, WatcherCmd::WatchThis(target)).unwrap();
|
||||||
|
rt.send_to(w3_addr, WatcherCmd::WatchThis(target)).unwrap();
|
||||||
|
rt.send_to(w4_addr, WatcherCmd::WatchThis(target)).unwrap();
|
||||||
|
tick_n(&rt, 3);
|
||||||
|
|
||||||
|
// w2 double-watches (idempotent test)
|
||||||
|
rt.send_to(w2_addr, WatcherCmd::WatchThis(target)).unwrap();
|
||||||
|
tick_n(&rt, 3);
|
||||||
|
|
||||||
|
// w4 unwatches
|
||||||
|
rt.send_to(w4_addr, WatcherCmd::UnwatchThis(target)).unwrap();
|
||||||
|
tick_n(&rt, 3);
|
||||||
|
|
||||||
|
// Kill target
|
||||||
|
rt.send_to(target, PanicMsg).unwrap();
|
||||||
|
tick_n(&rt, 5);
|
||||||
|
|
||||||
|
assert_eq!(s1.count(), 1, "watcher 1 notified");
|
||||||
|
assert_eq!(s2.count(), 1, "double-watch still only one notification");
|
||||||
|
assert_eq!(s3.count(), 1, "watcher 3 notified");
|
||||||
|
assert_eq!(s4.count(), 0, "unwatched watcher not notified");
|
||||||
|
assert_eq!(s1.last_reason(), Some(ExitReason::Panicked));
|
||||||
|
|
||||||
|
// --- Runtime-level watch ---
|
||||||
|
let rt = std_runtime(RuntimeConfig::default());
|
||||||
|
let target = rt.spawn(PanicActor).unwrap();
|
||||||
|
let (w, s) = new_exit_watcher();
|
||||||
|
let w_addr = rt.spawn(w).unwrap();
|
||||||
|
tick_n(&rt, 2);
|
||||||
|
rt.watch(w_addr, target);
|
||||||
|
rt.send_to(target, PanicMsg).unwrap();
|
||||||
|
tick_n(&rt, 5);
|
||||||
|
assert_eq!(s.count(), 1, "runtime-level watch delivers notification");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Watch edge cases: watcher dies before target (no crash), self-watch (no
|
||||||
|
/// crash), watcher reacts to death by spawning a replacement.
|
||||||
|
#[test]
|
||||||
|
fn watch_edge_cases() {
|
||||||
|
// Watcher dies before target — no crash
|
||||||
|
let rt = std_runtime(RuntimeConfig::default());
|
||||||
|
let target = rt.spawn(PanicActor).unwrap();
|
||||||
|
let target2 = rt.spawn(PanicActor).unwrap();
|
||||||
|
rt.watch(target2, target);
|
||||||
|
tick_n(&rt, 3);
|
||||||
|
rt.send_to(target2, PanicMsg).unwrap(); // kill watcher first
|
||||||
|
tick_n(&rt, 5);
|
||||||
|
rt.send_to(target, PanicMsg).unwrap(); // kill target — no crash
|
||||||
|
tick_n(&rt, 5);
|
||||||
|
|
||||||
|
// Self-watch — no crash
|
||||||
|
let rt = std_runtime(RuntimeConfig::default());
|
||||||
|
let (w, _s) = new_exit_watcher();
|
||||||
|
let addr = rt.spawn(w).unwrap();
|
||||||
|
rt.send_to(addr, WatcherCmd::WatchThis(addr)).unwrap();
|
||||||
|
tick_n(&rt, 5);
|
||||||
|
|
||||||
|
// Watcher reacts to death by spawning replacement
|
||||||
|
let rt = std_runtime(RuntimeConfig::default());
|
||||||
|
let spawned = Arc::new(AtomicUsize::new(0));
|
||||||
|
|
||||||
|
struct SupervisorWatcher {
|
||||||
|
spawned_count: Arc<AtomicUsize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
enum SupCmd {
|
||||||
|
WatchThis(ActorAddress),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActorInterface for SupervisorWatcher {
|
||||||
|
type Incoming = SupCmd;
|
||||||
|
type Response = ();
|
||||||
|
fn handle(&mut self, ctx: &Ctx, msg: SupCmd) {
|
||||||
|
match msg {
|
||||||
|
SupCmd::WatchThis(target) => ctx.watch(target),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn on_actor_exit(&mut self, ctx: &Ctx, _exited: ActorExited) {
|
||||||
|
let _ = ctx.spawn(Sleeper);
|
||||||
|
self.spawned_count.fetch_add(1, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let target = rt.spawn(PanicActor).unwrap();
|
||||||
|
let sup = rt.spawn(SupervisorWatcher { spawned_count: spawned.clone() }).unwrap();
|
||||||
|
rt.send_to(sup, SupCmd::WatchThis(target)).unwrap();
|
||||||
|
tick_n(&rt, 3);
|
||||||
|
rt.send_to(target, PanicMsg).unwrap();
|
||||||
|
tick_n(&rt, 5);
|
||||||
|
assert_eq!(spawned.load(Ordering::SeqCst), 1, "watcher spawned replacement");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Monitor API contract: Down on stop (Normal) and panic (Panicked), multiple
|
||||||
|
/// monitors, demonitor cancels, dead watcher cleanup, stacked monitors,
|
||||||
|
/// external inbox, handle_down dispatch.
|
||||||
|
#[test]
|
||||||
|
fn monitor_death_notification_contract() {
|
||||||
|
let rt = std_runtime(RuntimeConfig::default());
|
||||||
|
|
||||||
|
// --- Stop → Down(Normal) ---
|
||||||
|
let inbox = rt.new_inbox::<Down>().unwrap();
|
||||||
|
let target = rt.spawn(PingPongActor).unwrap();
|
||||||
|
rt.spawn(MonitorWatcherActor {
|
||||||
|
watch_target: target,
|
||||||
|
reply_to: *inbox.addr(),
|
||||||
|
mref: None,
|
||||||
|
}).unwrap();
|
||||||
|
rt.tick();
|
||||||
|
rt.stop_actor(target).unwrap();
|
||||||
|
tick_n(&rt, 3);
|
||||||
|
let down = inbox.try_recv().expect("Down on graceful stop");
|
||||||
|
assert_eq!(down.addr, target);
|
||||||
|
assert_eq!(down.reason, StopReason::Normal);
|
||||||
|
|
||||||
|
// --- Panic → Down(Panicked) ---
|
||||||
|
let rt = std_runtime(RuntimeConfig::default());
|
||||||
|
let inbox = rt.new_inbox::<Down>().unwrap();
|
||||||
|
let target = rt.spawn(PanicActor).unwrap();
|
||||||
|
rt.spawn(MonitorWatcherActor {
|
||||||
|
watch_target: target,
|
||||||
|
reply_to: *inbox.addr(),
|
||||||
|
mref: None,
|
||||||
|
}).unwrap();
|
||||||
|
rt.tick();
|
||||||
|
rt.send_to(target, PanicMsg).unwrap();
|
||||||
|
tick_n(&rt, 3);
|
||||||
|
let down = inbox.try_recv().expect("Down on panic");
|
||||||
|
assert_eq!(down.reason, StopReason::Panicked);
|
||||||
|
|
||||||
|
// --- Multiple monitors ---
|
||||||
|
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(MonitorWatcherActor {
|
||||||
|
watch_target: target, reply_to: *inbox1.addr(), mref: None,
|
||||||
|
}).unwrap();
|
||||||
|
rt.spawn(MonitorWatcherActor {
|
||||||
|
watch_target: target, reply_to: *inbox2.addr(), mref: None,
|
||||||
|
}).unwrap();
|
||||||
|
rt.tick();
|
||||||
|
rt.stop_actor(target).unwrap();
|
||||||
|
tick_n(&rt, 3);
|
||||||
|
assert!(inbox1.try_recv().is_some(), "watcher 1 notified");
|
||||||
|
assert!(inbox2.try_recv().is_some(), "watcher 2 notified");
|
||||||
|
|
||||||
|
// --- Demonitor cancels ---
|
||||||
|
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();
|
||||||
|
rt.send_to(watcher, Ping { reply_to: ActorAddress::default() }).unwrap();
|
||||||
|
rt.tick(); // demonitor
|
||||||
|
rt.stop_actor(target).unwrap();
|
||||||
|
tick_n(&rt, 3);
|
||||||
|
assert!(down_inbox.try_recv().is_none(), "demonitored: no Down delivered");
|
||||||
|
|
||||||
|
// --- Dead watcher cleaned up ---
|
||||||
|
let rt = std_runtime(RuntimeConfig::default());
|
||||||
|
let target = rt.spawn(PingPongActor).unwrap();
|
||||||
|
let watcher = rt.spawn(MonitorWatcherActor {
|
||||||
|
watch_target: target,
|
||||||
|
reply_to: ActorAddress::default(),
|
||||||
|
mref: None,
|
||||||
|
}).unwrap();
|
||||||
|
rt.tick();
|
||||||
|
rt.stop_actor(watcher).unwrap();
|
||||||
|
rt.tick(); // watcher dies
|
||||||
|
rt.stop_actor(target).unwrap();
|
||||||
|
tick_n(&rt, 3); // target dies — no crash trying to deliver to dead watcher
|
||||||
|
|
||||||
|
// --- Stacked monitors produce multiple notifications ---
|
||||||
|
struct DoubleMonitor {
|
||||||
|
target: ActorAddress,
|
||||||
|
reply_to: ActorAddress,
|
||||||
|
}
|
||||||
|
impl ActorInterface for DoubleMonitor {
|
||||||
|
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(DoubleMonitor { target, reply_to: *inbox.addr() }).unwrap();
|
||||||
|
rt.tick();
|
||||||
|
rt.stop_actor(target).unwrap();
|
||||||
|
tick_n(&rt, 3);
|
||||||
|
assert!(inbox.try_recv().is_some(), "first Down from stacked monitor");
|
||||||
|
assert!(inbox.try_recv().is_some(), "second Down from stacked monitor");
|
||||||
|
assert!(inbox.try_recv().is_none(), "no more");
|
||||||
|
|
||||||
|
// --- handle_down dispatch ---
|
||||||
|
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 target = rt.spawn(PanicActor).unwrap();
|
||||||
|
let tracker = rt.spawn(MonitoringTracker {
|
||||||
|
target,
|
||||||
|
downs: vec![],
|
||||||
|
inbox: *inbox.addr(),
|
||||||
|
}).unwrap();
|
||||||
|
rt.tick();
|
||||||
|
rt.send_to(target, PanicMsg).unwrap();
|
||||||
|
tick_n(&rt, 3);
|
||||||
|
rt.send_to(tracker, Ping { reply_to: ActorAddress::default() }).unwrap();
|
||||||
|
rt.tick();
|
||||||
|
assert_eq!(inbox.try_recv(), Some(Count(1)), "handle_down received exactly one Down");
|
||||||
|
|
||||||
|
// --- When Incoming=Down, handle_down is NOT called ---
|
||||||
|
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 target = rt.spawn(PanicActor).unwrap();
|
||||||
|
rt.spawn(DownAsIncoming { target, inbox: *inbox.addr() }).unwrap();
|
||||||
|
rt.tick();
|
||||||
|
rt.send_to(target, PanicMsg).unwrap();
|
||||||
|
tick_n(&rt, 3);
|
||||||
|
let received = inbox.try_recv().expect("Down delivered via handle(), not handle_down");
|
||||||
|
assert_eq!(received.reason, StopReason::Panicked);
|
||||||
|
}
|
||||||
255
tests/common/mod.rs
Normal file
255
tests/common/mod.rs
Normal file
|
|
@ -0,0 +1,255 @@
|
||||||
|
// 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, ActorExited, ActorInterface, Down, ExitReason, MonitorRef, StopReason,
|
||||||
|
};
|
||||||
|
pub use swactor::runtime::{Ctx, Inbox, MailboxOverflow, Runtime, RuntimeConfig};
|
||||||
|
pub use swactor_std::{
|
||||||
|
ChildSpec, CtxGroups, CtxMonitoring, CtxNaming, CtxTimers, CtxWatching, RestartPolicy, Router,
|
||||||
|
RoutingStrategy, RuntimeGroups, RuntimeNaming, RuntimeWatching, 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 exactly `n` times (no inbox polling).
|
||||||
|
pub fn tick_n(rt: &Runtime, n: usize) {
|
||||||
|
for _ in 0..n {
|
||||||
|
rt.tick();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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()
|
||||||
|
}
|
||||||
489
tests/message_delivery.rs
Normal file
489
tests/message_delivery.rs
Normal file
|
|
@ -0,0 +1,489 @@
|
||||||
|
//! Message Delivery Tests — how data flows through the system.
|
||||||
|
//!
|
||||||
|
//! Covers: FIFO ordering, routing correctness at scale, delivery from within
|
||||||
|
//! handlers, address error handling, fairness/budgets, timers, and mailbox
|
||||||
|
//! backpressure policies.
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
use common::*;
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
// ── Local actors ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Sends a countdown message to itself, then replies Done(0).
|
||||||
|
struct SelfSendActor;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct Countdown {
|
||||||
|
remaining: usize,
|
||||||
|
reply_to: ActorAddress,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActorInterface for SelfSendActor {
|
||||||
|
type Incoming = Countdown;
|
||||||
|
type Response = Done;
|
||||||
|
fn handle(&mut self, ctx: &Ctx, msg: Countdown) {
|
||||||
|
if msg.remaining == 0 {
|
||||||
|
let _ = ctx.send(msg.reply_to, Done(0));
|
||||||
|
} else {
|
||||||
|
let _ = ctx.send(
|
||||||
|
ctx.self_addr(),
|
||||||
|
Countdown { remaining: msg.remaining - 1, reply_to: msg.reply_to },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schedules a one-shot timer in on_start.
|
||||||
|
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) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schedules a one-shot timer from a handler.
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schedules an interval timer on start.
|
||||||
|
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) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// NumberedMsg/Reply for routing correctness tests.
|
||||||
|
#[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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ring node for routing chain test.
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct RingHop {
|
||||||
|
hops_remaining: usize,
|
||||||
|
final_dest: ActorAddress,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
struct RingDone(usize);
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Tests
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// Messages arrive in FIFO order even with small buffers, budget constraints,
|
||||||
|
/// and independent mailboxes isolate actors from each other.
|
||||||
|
#[test]
|
||||||
|
fn fifo_ordering_and_mailbox_isolation() {
|
||||||
|
// FIFO with small buffer and budget
|
||||||
|
let rt = std_runtime(RuntimeConfig {
|
||||||
|
channel_buffer_size: 1,
|
||||||
|
actor_message_budget: 8,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let addr = rt.spawn(CounterActor { count: 0 }).unwrap();
|
||||||
|
let inbox = rt.new_inbox::<Count>().unwrap();
|
||||||
|
for _ in 0..100 {
|
||||||
|
rt.send_to(addr, Increment { reply_to: *inbox.addr() }).unwrap();
|
||||||
|
}
|
||||||
|
let replies: Vec<_> = tick_and_drain(&rt, &inbox, 50);
|
||||||
|
assert_eq!(replies.len(), 100, "all messages delivered");
|
||||||
|
for (i, reply) in replies.iter().enumerate() {
|
||||||
|
assert_eq!(*reply, Count(i + 1), "FIFO order preserved at position {i}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mailbox isolation: 3 actors each get exactly their own messages
|
||||||
|
let rt = std_runtime(RuntimeConfig::default());
|
||||||
|
let mut inboxes = Vec::new();
|
||||||
|
for _ in 0..3 {
|
||||||
|
let addr = rt.spawn(PingPongActor).unwrap();
|
||||||
|
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||||
|
rt.send_to(addr, Ping { reply_to: *inbox.addr() }).unwrap();
|
||||||
|
inboxes.push(inbox);
|
||||||
|
}
|
||||||
|
tick_n(&rt, 10);
|
||||||
|
for (i, inbox) in inboxes.iter().enumerate() {
|
||||||
|
assert!(inbox.try_recv().is_some(), "actor {i} replied");
|
||||||
|
assert!(inbox.try_recv().is_none(), "actor {i} has exactly one reply");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 200 actors each get a unique numbered message and reply correctly.
|
||||||
|
/// A 100-hop ring traversal completes.
|
||||||
|
#[test]
|
||||||
|
fn message_routing_at_scale() {
|
||||||
|
// 200-actor numbered routing
|
||||||
|
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();
|
||||||
|
let mut addrs = Vec::new();
|
||||||
|
for _ in 0..200 {
|
||||||
|
addrs.push(rt.spawn(NumberedActor).unwrap());
|
||||||
|
}
|
||||||
|
rt.tick();
|
||||||
|
for (i, addr) in addrs.iter().enumerate() {
|
||||||
|
rt.send_to(*addr, NumberedMsg { n: i, reply_to: inbox_addr }).unwrap();
|
||||||
|
}
|
||||||
|
tick_n(&rt, 3);
|
||||||
|
let replies: Vec<NumberedReply> = std::iter::from_fn(|| inbox.try_recv()).collect();
|
||||||
|
assert_eq!(replies.len(), 200, "all 200 actors replied");
|
||||||
|
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 correct actor");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 100-hop ring
|
||||||
|
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();
|
||||||
|
let mut ring_addrs = Vec::new();
|
||||||
|
let mut next = inbox_addr;
|
||||||
|
for _ in (0..100).rev() {
|
||||||
|
let addr = rt.spawn(RingNode { next }).unwrap();
|
||||||
|
ring_addrs.push(addr);
|
||||||
|
next = addr;
|
||||||
|
}
|
||||||
|
ring_addrs.reverse();
|
||||||
|
rt.tick();
|
||||||
|
rt.send_to(ring_addrs[0], RingHop { hops_remaining: 99, final_dest: inbox_addr }).unwrap();
|
||||||
|
let result = tick_until_recv(&rt, &inbox, 110);
|
||||||
|
assert_eq!(result, Some(RingDone(100)), "ring message traverses all 100 hops");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Messages sent in handlers are delivered: delegation, self-send chains,
|
||||||
|
/// rapid spawn+immediate-send, multiple inbox types coexist.
|
||||||
|
#[test]
|
||||||
|
fn delivery_from_within_handlers() {
|
||||||
|
let rt = std_runtime(RuntimeConfig::default());
|
||||||
|
|
||||||
|
// Delegation: spawn+send in handler
|
||||||
|
let delegator = rt.spawn(DelegatorActor).unwrap();
|
||||||
|
let inbox = rt.new_inbox::<Done>().unwrap();
|
||||||
|
rt.send_to(delegator, Forward { value: 5, reply_to: *inbox.addr() }).unwrap();
|
||||||
|
let reply = tick_until_recv(&rt, &inbox, 20);
|
||||||
|
assert_eq!(reply, Some(Done(10)), "child spawned during handler receives message");
|
||||||
|
|
||||||
|
// Self-send countdown of 20
|
||||||
|
let self_sender = rt.spawn(SelfSendActor).unwrap();
|
||||||
|
rt.send_to(self_sender, Countdown { remaining: 20, reply_to: *inbox.addr() }).unwrap();
|
||||||
|
let reply = tick_until_recv(&rt, &inbox, 50);
|
||||||
|
assert_eq!(reply, Some(Done(0)), "self-send chain completes");
|
||||||
|
|
||||||
|
// Multiple senders reach same actor
|
||||||
|
let counter = rt.spawn(CounterActor { count: 0 }).unwrap();
|
||||||
|
let inbox_a = rt.new_inbox::<Count>().unwrap();
|
||||||
|
let inbox_b = rt.new_inbox::<Count>().unwrap();
|
||||||
|
rt.send_to(counter, Increment { reply_to: *inbox_a.addr() }).unwrap();
|
||||||
|
rt.send_to(counter, Increment { reply_to: *inbox_b.addr() }).unwrap();
|
||||||
|
tick_n(&rt, 10);
|
||||||
|
assert!(inbox_a.try_recv().is_some());
|
||||||
|
assert_eq!(inbox_b.try_recv(), Some(Count(2)), "both senders reach same actor");
|
||||||
|
|
||||||
|
// 50 rapid spawn+immediate-send pairs
|
||||||
|
let rt = std_runtime(RuntimeConfig::default());
|
||||||
|
let pong_inbox = rt.new_inbox::<Pong>().unwrap();
|
||||||
|
for _ in 0..50 {
|
||||||
|
let addr = rt.spawn(PingPongActor).unwrap();
|
||||||
|
rt.send_to(addr, Ping { reply_to: *pong_inbox.addr() }).unwrap();
|
||||||
|
}
|
||||||
|
let replies = tick_and_drain(&rt, &pong_inbox, 50);
|
||||||
|
assert_eq!(replies.len(), 50, "all spawn+send pairs complete");
|
||||||
|
|
||||||
|
// Multiple inbox types coexist
|
||||||
|
let rt = std_runtime(RuntimeConfig::default());
|
||||||
|
let counter_addr = rt.spawn(CounterActor { count: 0 }).unwrap();
|
||||||
|
let pinger_addr = rt.spawn(PingPongActor).unwrap();
|
||||||
|
let count_inbox = rt.new_inbox::<Count>().unwrap();
|
||||||
|
let pong_inbox = rt.new_inbox::<Pong>().unwrap();
|
||||||
|
rt.send_to(counter_addr, Increment { reply_to: *count_inbox.addr() }).unwrap();
|
||||||
|
rt.send_to(pinger_addr, Ping { reply_to: *pong_inbox.addr() }).unwrap();
|
||||||
|
tick_n(&rt, 10);
|
||||||
|
assert_eq!(count_inbox.try_recv(), Some(Count(1)));
|
||||||
|
assert_eq!(pong_inbox.try_recv(), Some(Pong));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sending to nonexistent address returns error, wrong type increments
|
||||||
|
/// type_mismatch counter.
|
||||||
|
#[test]
|
||||||
|
fn address_error_handling() {
|
||||||
|
let rt = std_runtime(RuntimeConfig::default());
|
||||||
|
|
||||||
|
// Nonexistent address
|
||||||
|
let bogus = ActorAddress::new_random();
|
||||||
|
assert!(rt.send_to(bogus, Pong).is_err(), "send to unknown address fails");
|
||||||
|
|
||||||
|
// Wrong type
|
||||||
|
let addr = rt.spawn(PingPongActor).unwrap();
|
||||||
|
rt.send_to(addr, Count(42)).unwrap(); // Count instead of Ping
|
||||||
|
rt.send_to(addr, Count(0)).unwrap();
|
||||||
|
rt.send_to(addr, Count(0)).unwrap();
|
||||||
|
tick_n(&rt, 10);
|
||||||
|
let stats = rt.stats();
|
||||||
|
let mismatches: u64 = stats.workers.iter().map(|w| w.type_mismatches).sum();
|
||||||
|
assert_eq!(mismatches, 3, "3 wrong-type messages counted as mismatches");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Budget fairness: hot actor doesn't starve cold actor, budget is respected
|
||||||
|
/// with self-sends, unlimited budget drains all.
|
||||||
|
#[test]
|
||||||
|
fn fairness_budget_prevents_starvation() {
|
||||||
|
// Hot (1000 msgs) vs cold (1 msg), budget=64
|
||||||
|
let rt = std_runtime(RuntimeConfig::default());
|
||||||
|
let hot_counter = Arc::new(AtomicUsize::new(0));
|
||||||
|
let cold_inbox = rt.new_inbox::<Pong>().unwrap();
|
||||||
|
let hot = rt.spawn(CountingPingActor { counter: hot_counter.clone() }).unwrap();
|
||||||
|
let cold = rt.spawn(PingPongActor).unwrap();
|
||||||
|
let dummy = rt.new_inbox::<Pong>().unwrap();
|
||||||
|
for _ in 0..1000 {
|
||||||
|
rt.send_to(hot, Ping { reply_to: *dummy.addr() }).unwrap();
|
||||||
|
}
|
||||||
|
rt.send_to(cold, Ping { reply_to: *cold_inbox.addr() }).unwrap();
|
||||||
|
rt.tick();
|
||||||
|
assert!(cold_inbox.try_recv().is_some(), "cold actor not starved by hot actor");
|
||||||
|
assert!(hot_counter.load(Ordering::SeqCst) <= 64, "hot capped at budget");
|
||||||
|
|
||||||
|
// Budget=4 with self-send chain of 20 → completes across multiple ticks
|
||||||
|
let rt = std_runtime(RuntimeConfig { actor_message_budget: 4, ..Default::default() });
|
||||||
|
let addr = rt.spawn(SelfSendActor).unwrap();
|
||||||
|
let inbox = rt.new_inbox::<Done>().unwrap();
|
||||||
|
rt.send_to(addr, Countdown { remaining: 20, reply_to: *inbox.addr() }).unwrap();
|
||||||
|
tick_n(&rt, 30);
|
||||||
|
assert_eq!(inbox.try_recv(), Some(Done(0)), "self-send chain completes despite budget");
|
||||||
|
|
||||||
|
// Unlimited budget (0) drains all
|
||||||
|
let rt = std_runtime(RuntimeConfig { actor_message_budget: 0, ..Default::default() });
|
||||||
|
let counter = Arc::new(AtomicUsize::new(0));
|
||||||
|
let dummy = rt.new_inbox::<Pong>().unwrap();
|
||||||
|
let addr = rt.spawn(CountingPingActor { counter: counter.clone() }).unwrap();
|
||||||
|
for _ in 0..500 {
|
||||||
|
rt.send_to(addr, Ping { reply_to: *dummy.addr() }).unwrap();
|
||||||
|
}
|
||||||
|
rt.tick();
|
||||||
|
rt.tick();
|
||||||
|
assert_eq!(counter.load(Ordering::SeqCst), 500, "unlimited budget drains all");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One-shot timers fire at the right tick and only once. Interval timers fire
|
||||||
|
/// repeatedly at the right period. Timers are cleaned up when actors die.
|
||||||
|
#[test]
|
||||||
|
fn timer_one_shot_and_interval() {
|
||||||
|
// One-shot: delay=3 from on_start
|
||||||
|
let rt = std_runtime(RuntimeConfig::default());
|
||||||
|
let inbox = rt.new_inbox::<Ping>().unwrap();
|
||||||
|
rt.spawn(TimerStartActor { target: *inbox.addr(), delay_ticks: 3 }).unwrap();
|
||||||
|
rt.tick(); // tick 1: on_start schedules
|
||||||
|
assert!(inbox.try_recv().is_none(), "no delivery tick 1");
|
||||||
|
rt.tick(); // tick 2
|
||||||
|
assert!(inbox.try_recv().is_none(), "no delivery tick 2");
|
||||||
|
rt.tick(); // tick 3
|
||||||
|
assert!(inbox.try_recv().is_none(), "no delivery tick 3");
|
||||||
|
rt.tick(); // tick 4: fires
|
||||||
|
assert!(inbox.try_recv().is_some(), "timer fires after 3-tick delay");
|
||||||
|
|
||||||
|
// One-shot from handler
|
||||||
|
let rt = std_runtime(RuntimeConfig::default());
|
||||||
|
let inbox = rt.new_inbox::<Done>().unwrap();
|
||||||
|
let addr = rt.spawn(DelayPingPongActor).unwrap();
|
||||||
|
rt.send_to(addr, Forward { value: 42, reply_to: *inbox.addr() }).unwrap();
|
||||||
|
rt.tick(); // process Forward, schedule timer
|
||||||
|
assert!(inbox.try_recv().is_none());
|
||||||
|
rt.tick(); // tick 2
|
||||||
|
rt.tick(); // tick 3
|
||||||
|
assert!(inbox.try_recv().is_none());
|
||||||
|
rt.tick(); // tick 4: fires
|
||||||
|
assert_eq!(inbox.try_recv(), Some(Done(42)), "delayed reply from handler timer");
|
||||||
|
|
||||||
|
// One-shot does NOT repeat
|
||||||
|
let rt = std_runtime(RuntimeConfig::default());
|
||||||
|
let inbox = rt.new_inbox::<Ping>().unwrap();
|
||||||
|
rt.spawn(TimerStartActor { target: *inbox.addr(), delay_ticks: 1 }).unwrap();
|
||||||
|
rt.tick(); // schedule
|
||||||
|
rt.tick(); // fires
|
||||||
|
assert!(inbox.try_recv().is_some(), "first fire");
|
||||||
|
tick_n(&rt, 5);
|
||||||
|
assert!(inbox.try_recv().is_none(), "one-shot doesn't repeat");
|
||||||
|
|
||||||
|
// Zero-delay fires next tick
|
||||||
|
let rt = std_runtime(RuntimeConfig::default());
|
||||||
|
let inbox = rt.new_inbox::<Ping>().unwrap();
|
||||||
|
rt.spawn(TimerStartActor { target: *inbox.addr(), delay_ticks: 0 }).unwrap();
|
||||||
|
rt.tick(); // schedule
|
||||||
|
assert!(inbox.try_recv().is_none(), "not immediate — fires next tick");
|
||||||
|
rt.tick(); // fires
|
||||||
|
assert!(inbox.try_recv().is_some(), "zero-delay fires next tick");
|
||||||
|
|
||||||
|
// Interval: period=2, fires on ticks 3, 5, 7
|
||||||
|
let rt = std_runtime(RuntimeConfig::default());
|
||||||
|
let inbox = rt.new_inbox::<Ping>().unwrap();
|
||||||
|
rt.spawn(HeartbeatActor { target: *inbox.addr(), period: 2 }).unwrap();
|
||||||
|
rt.tick(); // tick 1: schedule
|
||||||
|
assert!(inbox.try_recv().is_none());
|
||||||
|
rt.tick(); // tick 2
|
||||||
|
assert!(inbox.try_recv().is_none());
|
||||||
|
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());
|
||||||
|
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());
|
||||||
|
rt.tick(); // tick 7: third fire
|
||||||
|
assert!(inbox.try_recv().is_some(), "fire on tick 7");
|
||||||
|
|
||||||
|
// Timer cleanup when target actor dies
|
||||||
|
let rt = std_runtime(RuntimeConfig::default());
|
||||||
|
let counter_addr = rt.spawn(CounterActor { count: 0 }).unwrap();
|
||||||
|
rt.spawn(HeartbeatActor { target: counter_addr, period: 1 }).unwrap();
|
||||||
|
tick_n(&rt, 3);
|
||||||
|
rt.stop_actor(counter_addr).unwrap();
|
||||||
|
tick_n(&rt, 5);
|
||||||
|
let stats = rt.stats();
|
||||||
|
assert_eq!(stats.workers[0].num_actors, 1, "only heartbeat actor remains");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bounded mailboxes: DropNewest caps at capacity, DropOldest keeps newest,
|
||||||
|
/// unbounded delivers all, mailbox refills after processing.
|
||||||
|
#[test]
|
||||||
|
fn mailbox_backpressure_policies() {
|
||||||
|
// DropNewest: capacity=10, send 50 → only 10 delivered
|
||||||
|
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();
|
||||||
|
for _ in 0..50 {
|
||||||
|
let _ = rt.send_to(addr, Increment { reply_to: *inbox.addr() });
|
||||||
|
}
|
||||||
|
tick_n(&rt, 20);
|
||||||
|
let mut replies = 0;
|
||||||
|
while inbox.try_recv().is_some() { replies += 1; }
|
||||||
|
assert_eq!(replies, 10, "DropNewest caps at mailbox capacity");
|
||||||
|
let drops: u64 = rt.stats().workers.iter().map(|w| w.messages_dropped).sum();
|
||||||
|
assert_eq!(drops, 40, "40 messages dropped");
|
||||||
|
|
||||||
|
// DropOldest: capacity=5, send 10 → newest 5 kept
|
||||||
|
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();
|
||||||
|
for i in 0..10 {
|
||||||
|
let _ = rt.send_to(addr, Forward { value: i, reply_to: *inbox.addr() });
|
||||||
|
}
|
||||||
|
tick_n(&rt, 10);
|
||||||
|
let mut replies = Vec::new();
|
||||||
|
while let Some(Done(v)) = inbox.try_recv() { replies.push(v); }
|
||||||
|
assert_eq!(replies.len(), 5, "only 5 kept");
|
||||||
|
assert_eq!(replies, vec![10, 12, 14, 16, 18], "newest values kept (5-9 doubled)");
|
||||||
|
|
||||||
|
// Unbounded: 200 messages all delivered
|
||||||
|
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() });
|
||||||
|
}
|
||||||
|
tick_n(&rt, 50);
|
||||||
|
let mut count = 0;
|
||||||
|
while inbox.try_recv().is_some() { count += 1; }
|
||||||
|
assert_eq!(count, 200, "unbounded delivers all");
|
||||||
|
|
||||||
|
// Refill 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();
|
||||||
|
for _ in 0..5 {
|
||||||
|
let _ = rt.send_to(addr, Increment { reply_to: *inbox.addr() });
|
||||||
|
}
|
||||||
|
rt.tick(); // process batch 1
|
||||||
|
for _ in 0..5 {
|
||||||
|
let _ = rt.send_to(addr, Increment { reply_to: *inbox.addr() });
|
||||||
|
}
|
||||||
|
rt.tick(); // process batch 2
|
||||||
|
let mut count = 0;
|
||||||
|
while inbox.try_recv().is_some() { count += 1; }
|
||||||
|
assert_eq!(count, 10, "mailbox refills after draining");
|
||||||
|
}
|
||||||
|
|
@ -8,9 +8,12 @@ use std::collections::HashMap;
|
||||||
use proptest::prelude::*;
|
use proptest::prelude::*;
|
||||||
use proptest_state_machine::{prop_state_machine, ReferenceStateMachine, StateMachineTest};
|
use proptest_state_machine::{prop_state_machine, ReferenceStateMachine, StateMachineTest};
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use swactor::actor::{ActorAddress, ActorInterface};
|
use swactor::actor::{ActorAddress, ActorInterface};
|
||||||
use swactor::config::{MailboxOverflow, RuntimeConfig};
|
use swactor::config::{MailboxOverflow, RuntimeConfig};
|
||||||
use swactor::runtime::{Ctx, Inbox, Runtime};
|
use swactor::runtime::{Ctx, Inbox, Runtime};
|
||||||
|
use swactor_std::{CtxTimers, StdExtension};
|
||||||
|
|
||||||
// ─── Shared Actor Types ────────────────────────────────────────────────────
|
// ─── Shared Actor Types ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -141,7 +144,8 @@ proptest! {
|
||||||
/// One-shot timer fires at exactly the right tick for any delay.
|
/// One-shot timer fires at exactly the right tick for any delay.
|
||||||
#[test]
|
#[test]
|
||||||
fn one_shot_timer_fires_at_correct_tick(delay in 1u64..20) {
|
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();
|
let inbox = rt.new_inbox::<Ping>().unwrap();
|
||||||
|
|
||||||
struct TimerActor { target: ActorAddress, delay: u64 }
|
struct TimerActor { target: ActorAddress, delay: u64 }
|
||||||
|
|
@ -175,7 +179,8 @@ proptest! {
|
||||||
/// Interval timer fires at correct periodic ticks for any period.
|
/// Interval timer fires at correct periodic ticks for any period.
|
||||||
#[test]
|
#[test]
|
||||||
fn interval_timer_fires_at_correct_period(period in 1u64..10) {
|
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();
|
let inbox = rt.new_inbox::<Ping>().unwrap();
|
||||||
|
|
||||||
struct IntervalActor { target: ActorAddress, period: u64 }
|
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
364
tests/runtime_stress.rs
Normal file
364
tests/runtime_stress.rs
Normal file
|
|
@ -0,0 +1,364 @@
|
||||||
|
//! Runtime Stress Tests — multi-threaded execution, parking, placement, and scale.
|
||||||
|
//!
|
||||||
|
//! Covers: single vs multi-threaded processing, high-volume MT delivery,
|
||||||
|
//! panic isolation under load, worker parking/shutdown, sustained throughput,
|
||||||
|
//! and load-aware actor placement.
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
use common::*;
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Poll `inbox` until a message arrives or `timeout` elapses.
|
||||||
|
fn poll_inbox<M: swactor::actor::Message>(
|
||||||
|
inbox: &Inbox<M>,
|
||||||
|
timeout: Duration,
|
||||||
|
) -> Option<M> {
|
||||||
|
let deadline = Instant::now() + timeout;
|
||||||
|
loop {
|
||||||
|
if let Some(msg) = inbox.try_recv() {
|
||||||
|
return Some(msg);
|
||||||
|
}
|
||||||
|
if Instant::now() > deadline {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait until `counter` reaches `target` or `timeout` elapses.
|
||||||
|
fn wait_for_count(counter: &AtomicUsize, target: usize, timeout: Duration) -> usize {
|
||||||
|
let deadline = Instant::now() + timeout;
|
||||||
|
loop {
|
||||||
|
let n = counter.load(Ordering::SeqCst);
|
||||||
|
if n >= target {
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
if Instant::now() > deadline {
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Single-threaded vs multi-threaded runtime basics.
|
||||||
|
///
|
||||||
|
/// Story: We start with a single-threaded runtime driven by tick(), confirm
|
||||||
|
/// nothing happens without ticking, then graduate to a multi-threaded runtime
|
||||||
|
/// with run() and verify background processing, cross-worker delegation,
|
||||||
|
/// custom thread counts, and clean shutdown.
|
||||||
|
#[test]
|
||||||
|
fn single_vs_multi_threaded_basics() {
|
||||||
|
// ── Part A: Single-threaded requires tick() ──
|
||||||
|
let rt = std_runtime(RuntimeConfig::default());
|
||||||
|
let addr = rt.spawn(PingPongActor).unwrap();
|
||||||
|
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||||
|
rt.send_to(addr, Ping { reply_to: *inbox.addr() }).unwrap();
|
||||||
|
|
||||||
|
assert!(inbox.try_recv().is_none(), "no processing before tick");
|
||||||
|
tick_n(&rt, 2);
|
||||||
|
assert!(inbox.try_recv().is_some(), "tick() drives single-threaded processing");
|
||||||
|
|
||||||
|
// ── Part B: Multi-threaded processes without ticking ──
|
||||||
|
let rt_mt = std_runtime(RuntimeConfig { num_threads: 4, ..Default::default() });
|
||||||
|
let addr = rt_mt.spawn(PingPongActor).unwrap();
|
||||||
|
let inbox = rt_mt.new_inbox::<Pong>().unwrap();
|
||||||
|
rt_mt.send_to(addr, Ping { reply_to: *inbox.addr() }).unwrap();
|
||||||
|
|
||||||
|
let handle = rt_mt.run().unwrap();
|
||||||
|
let reply = poll_inbox(&inbox, Duration::from_secs(5));
|
||||||
|
assert!(reply.is_some(), "background workers process without manual ticking");
|
||||||
|
|
||||||
|
// ── Part C: Cross-worker delegation (2 threads, spawn child from handler) ──
|
||||||
|
let addr2 = handle.runtime.spawn(DelegatorActor).unwrap();
|
||||||
|
let done_inbox = handle.runtime.new_inbox::<Done>().unwrap();
|
||||||
|
handle.runtime.send_to(addr2, Forward { value: 3, reply_to: *done_inbox.addr() }).unwrap();
|
||||||
|
|
||||||
|
let reply = poll_inbox(&done_inbox, Duration::from_secs(5));
|
||||||
|
assert_eq!(reply, Some(Done(6)), "cross-worker delegation delivers reply");
|
||||||
|
|
||||||
|
// ── Part D: Custom thread count reflected in stats ──
|
||||||
|
let stats = handle.runtime.stats();
|
||||||
|
assert_eq!(stats.num_workers, 4, "runtime respects requested thread count");
|
||||||
|
|
||||||
|
// ── Part E: Clean shutdown ──
|
||||||
|
handle.shutdown();
|
||||||
|
handle.join();
|
||||||
|
// Test passes by not hanging.
|
||||||
|
}
|
||||||
|
|
||||||
|
/// High-volume multi-threaded delivery.
|
||||||
|
///
|
||||||
|
/// Story: We throw large workloads at a 4-thread runtime — 50 senders
|
||||||
|
/// each firing 100 messages at one receiver, 200 concurrent spawn+send
|
||||||
|
/// pairs, and a 50-level chain that must hop across workers.
|
||||||
|
#[test]
|
||||||
|
fn mt_high_volume_delivery() {
|
||||||
|
let cfg = || RuntimeConfig {
|
||||||
|
num_threads: 4,
|
||||||
|
max_actors: 5_000,
|
||||||
|
channel_buffer_size: 10_000,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Part A: 50 senders × 100 messages → one receiver ──
|
||||||
|
{
|
||||||
|
let rt = std_runtime(cfg());
|
||||||
|
let counter = Arc::new(AtomicUsize::new(0));
|
||||||
|
let dummy = rt.new_inbox::<Pong>().unwrap();
|
||||||
|
let receiver = rt.spawn(CountingPingActor { counter: counter.clone() }).unwrap();
|
||||||
|
|
||||||
|
let total_expected = 50 * 100;
|
||||||
|
for _ in 0..50 {
|
||||||
|
for _ in 0..100 {
|
||||||
|
rt.send_to(receiver, Ping { reply_to: *dummy.addr() }).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let handle = rt.run().unwrap();
|
||||||
|
let processed = wait_for_count(&counter, total_expected, Duration::from_secs(5));
|
||||||
|
handle.shutdown();
|
||||||
|
handle.join();
|
||||||
|
assert_eq!(processed, total_expected, "all 5000 messages delivered to single receiver");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Part B: 200 concurrent spawn+send pairs ──
|
||||||
|
{
|
||||||
|
let rt = std_runtime(cfg());
|
||||||
|
let counter = Arc::new(AtomicUsize::new(0));
|
||||||
|
let dummy = rt.new_inbox::<Pong>().unwrap();
|
||||||
|
for _ in 0..200 {
|
||||||
|
let a = rt.spawn(CountingPingActor { counter: counter.clone() }).unwrap();
|
||||||
|
rt.send_to(a, Ping { reply_to: *dummy.addr() }).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let handle = rt.run().unwrap();
|
||||||
|
let received = wait_for_count(&counter, 200, Duration::from_secs(5));
|
||||||
|
handle.shutdown();
|
||||||
|
handle.join();
|
||||||
|
assert_eq!(received, 200, "all 200 spawn+send pairs complete");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Part C: 50-level chain across workers ──
|
||||||
|
{
|
||||||
|
let rt = std_runtime(RuntimeConfig { num_threads: 2, max_actors: 5_000, ..Default::default() });
|
||||||
|
let addr = rt.spawn(ChainActor).unwrap();
|
||||||
|
let inbox = rt.new_inbox::<Done>().unwrap();
|
||||||
|
rt.send_to(addr, ChainMsg { remaining: 50, depth: 0, reply_to: *inbox.addr() }).unwrap();
|
||||||
|
|
||||||
|
let handle = rt.run().unwrap();
|
||||||
|
let reply = poll_inbox(&inbox, Duration::from_secs(5));
|
||||||
|
handle.shutdown();
|
||||||
|
handle.join();
|
||||||
|
assert_eq!(reply, Some(Done(50)), "50-level chain completes across workers");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Panic isolation under multi-threaded load.
|
||||||
|
///
|
||||||
|
/// Story: 10 panicking actors and 10 healthy actors on 4 threads — every
|
||||||
|
/// panic is isolated and all 1000 healthy messages are still processed.
|
||||||
|
#[test]
|
||||||
|
fn mt_panic_isolation_under_load() {
|
||||||
|
let rt = std_runtime(RuntimeConfig {
|
||||||
|
num_threads: 4,
|
||||||
|
max_actors: 5_000,
|
||||||
|
channel_buffer_size: 10_000,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
let counter = Arc::new(AtomicUsize::new(0));
|
||||||
|
let dummy = rt.new_inbox::<Pong>().unwrap();
|
||||||
|
|
||||||
|
let mut panic_addrs = Vec::new();
|
||||||
|
let mut healthy_addrs = Vec::new();
|
||||||
|
for _ in 0..10 {
|
||||||
|
panic_addrs.push(rt.spawn(PanicActor).unwrap());
|
||||||
|
healthy_addrs.push(rt.spawn(CountingPingActor { counter: counter.clone() }).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger panics and flood healthy actors.
|
||||||
|
for &addr in &panic_addrs {
|
||||||
|
rt.send_to(addr, PanicMsg).unwrap();
|
||||||
|
}
|
||||||
|
for &addr in &healthy_addrs {
|
||||||
|
for _ in 0..100 {
|
||||||
|
rt.send_to(addr, Ping { reply_to: *dummy.addr() }).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let handle = rt.run().unwrap();
|
||||||
|
let expected = 10 * 100;
|
||||||
|
let processed = wait_for_count(&counter, expected, Duration::from_secs(5));
|
||||||
|
handle.shutdown();
|
||||||
|
handle.join();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
processed, expected,
|
||||||
|
"all {expected} healthy messages processed despite panicking peers"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Worker parking and shutdown latency.
|
||||||
|
///
|
||||||
|
/// Story: Workers park after idle time. We verify they wake quickly on new
|
||||||
|
/// messages, that messages sent after run() are delivered, and that shutdown
|
||||||
|
/// wakes all parked workers promptly.
|
||||||
|
#[test]
|
||||||
|
fn worker_parking_and_shutdown() {
|
||||||
|
// ── Part A: Parked workers wake on send ──
|
||||||
|
let rt = std_runtime(RuntimeConfig { num_threads: 2, ..Default::default() });
|
||||||
|
let addr = rt.spawn(PingPongActor).unwrap();
|
||||||
|
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||||
|
|
||||||
|
let handle = rt.run().unwrap();
|
||||||
|
std::thread::sleep(Duration::from_millis(50)); // Let workers park.
|
||||||
|
|
||||||
|
let before = Instant::now();
|
||||||
|
handle.runtime.send_to(addr, Ping { reply_to: *inbox.addr() }).unwrap();
|
||||||
|
let reply = poll_inbox(&inbox, Duration::from_secs(1));
|
||||||
|
let latency = before.elapsed();
|
||||||
|
|
||||||
|
assert!(reply.is_some(), "parked worker should wake and process");
|
||||||
|
assert!(latency.as_millis() < 100, "wake latency should be <100ms, was {:?}", latency);
|
||||||
|
|
||||||
|
// ── Part B: Send after run() delivers ──
|
||||||
|
let addr2 = handle.runtime.spawn(PingPongActor).unwrap();
|
||||||
|
let inbox2 = handle.runtime.new_inbox::<Pong>().unwrap();
|
||||||
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
|
handle.runtime.send_to(addr2, Ping { reply_to: *inbox2.addr() }).unwrap();
|
||||||
|
|
||||||
|
let reply2 = poll_inbox(&inbox2, Duration::from_secs(5));
|
||||||
|
assert!(reply2.is_some(), "message sent after run() must be delivered");
|
||||||
|
|
||||||
|
handle.shutdown();
|
||||||
|
handle.join();
|
||||||
|
|
||||||
|
// ── Part C: Shutdown wakes parked workers quickly ──
|
||||||
|
let rt2 = std_runtime(RuntimeConfig { num_threads: 4, ..Default::default() });
|
||||||
|
let h2 = rt2.run().unwrap();
|
||||||
|
std::thread::sleep(Duration::from_millis(50)); // Let workers park.
|
||||||
|
|
||||||
|
let before = Instant::now();
|
||||||
|
h2.shutdown();
|
||||||
|
h2.join();
|
||||||
|
let shutdown_time = before.elapsed();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
shutdown_time.as_millis() < 500,
|
||||||
|
"shutdown should complete quickly with parked workers, took {:?}",
|
||||||
|
shutdown_time
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sustained throughput with no message loss.
|
||||||
|
///
|
||||||
|
/// Story: We send 10 batches of 100 messages, ticking between batches on a
|
||||||
|
/// single-threaded runtime. Each batch must make forward progress, and after
|
||||||
|
/// draining, all 1000 messages are accounted for.
|
||||||
|
#[test]
|
||||||
|
fn sustained_throughput_no_message_loss() {
|
||||||
|
let rt = std_runtime(RuntimeConfig::default());
|
||||||
|
let counter = Arc::new(AtomicUsize::new(0));
|
||||||
|
let dummy = rt.new_inbox::<Pong>().unwrap();
|
||||||
|
let addr = rt.spawn(CountingPingActor { counter: counter.clone() }).unwrap();
|
||||||
|
|
||||||
|
for batch in 0..10 {
|
||||||
|
for _ in 0..100 {
|
||||||
|
rt.send_to(addr, Ping { reply_to: *dummy.addr() }).unwrap();
|
||||||
|
}
|
||||||
|
tick_n(&rt, 5);
|
||||||
|
let processed = counter.load(Ordering::SeqCst);
|
||||||
|
assert!(
|
||||||
|
processed > batch * 50,
|
||||||
|
"batch {batch}: expected progress, only {processed} processed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain remaining.
|
||||||
|
tick_n(&rt, 100);
|
||||||
|
let total = counter.load(Ordering::SeqCst);
|
||||||
|
assert_eq!(total, 1000, "sustained load should not drop any messages");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load-aware actor placement.
|
||||||
|
///
|
||||||
|
/// Story: A fresh runtime falls back to round-robin (even distribution).
|
||||||
|
/// Under imbalanced load, new actors bias toward the lighter worker.
|
||||||
|
/// A single-worker runtime degrades gracefully.
|
||||||
|
#[test]
|
||||||
|
fn load_aware_actor_placement() {
|
||||||
|
// ── Part A: Round-robin fallback on fresh runtime (4 workers, 100 actors) ──
|
||||||
|
let rt = std_runtime(RuntimeConfig { num_threads: 4, ..Default::default() });
|
||||||
|
for _ in 0..100 {
|
||||||
|
rt.spawn(CounterActor { count: 0 }).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let handle = rt.run().unwrap();
|
||||||
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
|
|
||||||
|
let stats = handle.runtime.stats();
|
||||||
|
handle.shutdown();
|
||||||
|
handle.join();
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Part B: Imbalanced load biases toward lighter worker ──
|
||||||
|
let rt2 = std_runtime(RuntimeConfig { num_threads: 2, ..Default::default() });
|
||||||
|
let mut addrs = Vec::new();
|
||||||
|
for _ in 0..20 {
|
||||||
|
addrs.push(rt2.spawn(CounterActor { count: 0 }).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
let h2 = rt2.run().unwrap();
|
||||||
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
|
|
||||||
|
// Bombard the first 10 actors (likely worker 0) with messages.
|
||||||
|
for addr in &addrs[..10] {
|
||||||
|
for _ in 0..50 {
|
||||||
|
let _ = h2.runtime.send_to(*addr, Increment { reply_to: *addr });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
|
|
||||||
|
// Spawn 10 more — should bias toward lighter worker.
|
||||||
|
for _ in 0..10 {
|
||||||
|
h2.runtime.spawn(CounterActor { count: 0 }).unwrap();
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
|
|
||||||
|
let stats2 = h2.runtime.stats();
|
||||||
|
h2.shutdown();
|
||||||
|
h2.join();
|
||||||
|
|
||||||
|
let total_actors: usize = stats2.workers.iter().map(|w| w.num_actors).sum();
|
||||||
|
assert!(total_actors >= 20, "expected at least 20 actors, got {total_actors}");
|
||||||
|
assert!(
|
||||||
|
stats2.workers.iter().all(|w| w.num_actors > 0),
|
||||||
|
"both workers should have actors: {:?}",
|
||||||
|
stats2.workers.iter().map(|w| w.num_actors).collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Part C: Single-worker degrades gracefully ──
|
||||||
|
let rt3 = std_runtime(RuntimeConfig::default());
|
||||||
|
for _ in 0..50 {
|
||||||
|
rt3.spawn(CounterActor { count: 0 }).unwrap();
|
||||||
|
}
|
||||||
|
tick_n(&rt3, 10);
|
||||||
|
|
||||||
|
let stats3 = rt3.stats();
|
||||||
|
assert_eq!(stats3.workers.len(), 1);
|
||||||
|
assert_eq!(stats3.workers[0].num_actors, 50);
|
||||||
|
}
|
||||||
742
tests/std_extension.rs
Normal file
742
tests/std_extension.rs
Normal file
|
|
@ -0,0 +1,742 @@
|
||||||
|
//! StdExtension Tests — higher-level patterns from swactor-std.
|
||||||
|
//!
|
||||||
|
//! Covers: naming registry, groups/pub-sub, ask pattern, supervision
|
||||||
|
//! strategies and restart policies, and router work distribution.
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
use common::*;
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
// ── Local actors ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns a named child from a handler.
|
||||||
|
struct NamedSpawnerActor {
|
||||||
|
reply_to: ActorAddress,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActorInterface for NamedSpawnerActor {
|
||||||
|
type Incoming = Ping;
|
||||||
|
type Response = ();
|
||||||
|
fn handle(&mut self, ctx: &Ctx, _msg: Ping) {
|
||||||
|
if let Ok(addr) = ctx.spawn_named("child", PingPongActor) {
|
||||||
|
ctx.send(self.reply_to, MyAddr(addr)).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Panics after `trigger` 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops itself on first message.
|
||||||
|
struct StopsAfterFirst;
|
||||||
|
impl ActorInterface for StopsAfterFirst {
|
||||||
|
type Incoming = Ping;
|
||||||
|
type Response = ();
|
||||||
|
fn handle(&mut self, ctx: &Ctx, _msg: Ping) {
|
||||||
|
ctx.stop_self();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Naming Registry
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// Full naming lifecycle: register, lookup, send, duplicate fails, auto-unregister
|
||||||
|
/// on stop and panic, name reuse, registered_names list, manual unregister.
|
||||||
|
#[test]
|
||||||
|
fn naming_registry_lifecycle() {
|
||||||
|
let rt = std_runtime(RuntimeConfig::default());
|
||||||
|
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||||
|
|
||||||
|
// Register "alice", lookup, send Ping → Pong
|
||||||
|
let alice = rt.spawn_named("alice", PingPongActor).unwrap();
|
||||||
|
assert_eq!(rt.where_is("alice"), Some(alice));
|
||||||
|
rt.send_to(alice, Ping { reply_to: *inbox.addr() }).unwrap();
|
||||||
|
rt.tick();
|
||||||
|
assert!(inbox.try_recv().is_some(), "named actor processes messages");
|
||||||
|
|
||||||
|
// Duplicate fails, original binding preserved
|
||||||
|
assert!(rt.spawn_named("alice", PingPongActor).is_err());
|
||||||
|
assert_eq!(rt.where_is("alice"), Some(alice));
|
||||||
|
|
||||||
|
// Unknown name → None
|
||||||
|
assert_eq!(rt.where_is("ghost"), None);
|
||||||
|
|
||||||
|
// Stop "alice" → name freed
|
||||||
|
rt.stop_actor(alice).unwrap();
|
||||||
|
rt.tick();
|
||||||
|
assert_eq!(rt.where_is("alice"), None, "name freed after stop");
|
||||||
|
|
||||||
|
// Reuse the name
|
||||||
|
let alice2 = rt.spawn_named("alice", PingPongActor).unwrap();
|
||||||
|
assert_ne!(alice, alice2);
|
||||||
|
assert_eq!(rt.where_is("alice"), Some(alice2));
|
||||||
|
|
||||||
|
// Panic also frees the name
|
||||||
|
let bob = rt.spawn_named("bob", PanicActor).unwrap();
|
||||||
|
rt.tick();
|
||||||
|
rt.send_to(bob, PanicMsg).unwrap();
|
||||||
|
rt.tick();
|
||||||
|
assert_eq!(rt.where_is("bob"), None, "name freed after panic");
|
||||||
|
let _bob2 = rt.spawn_named("bob", PingPongActor).unwrap();
|
||||||
|
assert!(rt.where_is("bob").is_some());
|
||||||
|
|
||||||
|
// registered_names enumerates all
|
||||||
|
rt.spawn_named("gamma", PingPongActor).unwrap();
|
||||||
|
let mut names = rt.registered_names();
|
||||||
|
names.sort();
|
||||||
|
assert!(names.contains(&"alice".to_string()));
|
||||||
|
assert!(names.contains(&"bob".to_string()));
|
||||||
|
assert!(names.contains(&"gamma".to_string()));
|
||||||
|
|
||||||
|
// Manual unregister: name freed but actor lives
|
||||||
|
let charlie_inbox = rt.new_inbox::<Pong>().unwrap();
|
||||||
|
let charlie = rt.spawn_named("charlie", PingPongActor).unwrap();
|
||||||
|
rt.tick();
|
||||||
|
let removed = rt.unregister("charlie");
|
||||||
|
assert_eq!(removed, Some(charlie));
|
||||||
|
assert_eq!(rt.where_is("charlie"), None, "name freed by unregister");
|
||||||
|
rt.send_to(charlie, Ping { reply_to: *charlie_inbox.addr() }).unwrap();
|
||||||
|
rt.tick();
|
||||||
|
assert!(charlie_inbox.try_recv().is_some(), "actor still alive after name unregistered");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Actors resolve and register names from handlers using ctx.
|
||||||
|
#[test]
|
||||||
|
fn naming_from_actor_handlers() {
|
||||||
|
let rt = std_runtime(RuntimeConfig::default());
|
||||||
|
let inbox = rt.new_inbox::<MyAddr>().unwrap();
|
||||||
|
|
||||||
|
// ctx.where_is from handler
|
||||||
|
let target = rt.spawn_named("target", PingPongActor).unwrap();
|
||||||
|
let looker = rt.spawn(NameLookupActor {
|
||||||
|
target_name: "target",
|
||||||
|
reply_to: *inbox.addr(),
|
||||||
|
}).unwrap();
|
||||||
|
rt.tick();
|
||||||
|
rt.send_to(looker, Ping { reply_to: ActorAddress::default() }).unwrap();
|
||||||
|
tick_n(&rt, 3);
|
||||||
|
assert_eq!(inbox.try_recv(), Some(MyAddr(target)), "ctx.where_is resolves");
|
||||||
|
|
||||||
|
// ctx.spawn_named from handler
|
||||||
|
let spawner = rt.spawn(NamedSpawnerActor { reply_to: *inbox.addr() }).unwrap();
|
||||||
|
rt.tick();
|
||||||
|
rt.send_to(spawner, Ping { reply_to: ActorAddress::default() }).unwrap();
|
||||||
|
tick_n(&rt, 3);
|
||||||
|
let child_addr = inbox.try_recv().expect("child address returned");
|
||||||
|
assert_eq!(rt.where_is("child"), Some(child_addr.0), "name registered from handler");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Groups / Pub-Sub
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// Full groups lifecycle: join, publish broadcasts, leave stops delivery,
|
||||||
|
/// dead actor auto-removed, multi-group cleanup, empty group deleted,
|
||||||
|
/// join and publish from handlers.
|
||||||
|
#[test]
|
||||||
|
fn groups_pub_sub_lifecycle() {
|
||||||
|
let rt = std_runtime(RuntimeConfig::default());
|
||||||
|
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||||
|
|
||||||
|
// Join 3 actors, publish → all 3 get it
|
||||||
|
let a = rt.spawn(PingPongActor).unwrap();
|
||||||
|
let b = rt.spawn(PingPongActor).unwrap();
|
||||||
|
let c = rt.spawn(PingPongActor).unwrap();
|
||||||
|
rt.join_group(a, "workers");
|
||||||
|
rt.join_group(b, "workers");
|
||||||
|
rt.join_group(c, "workers");
|
||||||
|
rt.tick();
|
||||||
|
|
||||||
|
let count = rt.publish_to("workers", Ping { reply_to: *inbox.addr() });
|
||||||
|
assert_eq!(count, 3, "3 members, 3 messages sent");
|
||||||
|
rt.tick();
|
||||||
|
let mut pongs = 0;
|
||||||
|
while inbox.try_recv().is_some() { pongs += 1; }
|
||||||
|
assert_eq!(pongs, 3, "all 3 received");
|
||||||
|
|
||||||
|
// Leave stops delivery
|
||||||
|
rt.leave_group(c, "workers");
|
||||||
|
let count = rt.publish_to("workers", Ping { reply_to: *inbox.addr() });
|
||||||
|
assert_eq!(count, 2, "2 after leave");
|
||||||
|
rt.tick();
|
||||||
|
let mut pongs = 0;
|
||||||
|
while inbox.try_recv().is_some() { pongs += 1; }
|
||||||
|
assert_eq!(pongs, 2);
|
||||||
|
|
||||||
|
// Dead actor auto-removed
|
||||||
|
rt.stop_actor(b).unwrap();
|
||||||
|
rt.tick();
|
||||||
|
let count = rt.publish_to("workers", Ping { reply_to: *inbox.addr() });
|
||||||
|
assert_eq!(count, 1, "dead actor removed");
|
||||||
|
|
||||||
|
// Multi-group cleanup: actor in alpha/beta/gamma dies → all cleaned
|
||||||
|
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();
|
||||||
|
assert!(rt.group_members("alpha").is_empty());
|
||||||
|
assert!(rt.group_members("beta").is_empty());
|
||||||
|
assert!(rt.group_members("gamma").is_empty());
|
||||||
|
|
||||||
|
// 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 removed");
|
||||||
|
|
||||||
|
// Empty group query
|
||||||
|
let rt = std_runtime(RuntimeConfig::default());
|
||||||
|
assert!(rt.group_members("nonexistent").is_empty());
|
||||||
|
|
||||||
|
// ctx.join_group from on_start
|
||||||
|
struct GroupJoiner;
|
||||||
|
impl ActorInterface for GroupJoiner {
|
||||||
|
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 x = rt.spawn(GroupJoiner).unwrap();
|
||||||
|
let y = rt.spawn(GroupJoiner).unwrap();
|
||||||
|
rt.tick();
|
||||||
|
let members = rt.group_members("auto-joined");
|
||||||
|
assert_eq!(members.len(), 2);
|
||||||
|
assert!(members.contains(&x));
|
||||||
|
assert!(members.contains(&y));
|
||||||
|
|
||||||
|
// ctx.publish from handler
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct BroadcastCmd { reply_to: ActorAddress }
|
||||||
|
|
||||||
|
struct Broadcaster;
|
||||||
|
impl ActorInterface for Broadcaster {
|
||||||
|
type Incoming = BroadcastCmd;
|
||||||
|
type Response = ();
|
||||||
|
fn on_start(&mut self, ctx: &Ctx) {
|
||||||
|
ctx.join_group("bcast");
|
||||||
|
}
|
||||||
|
fn handle(&mut self, ctx: &Ctx, msg: BroadcastCmd) {
|
||||||
|
ctx.publish("bcast", Ping { reply_to: msg.reply_to });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let rt = std_runtime(RuntimeConfig::default());
|
||||||
|
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||||
|
let p1 = rt.spawn(PingPongActor).unwrap();
|
||||||
|
let p2 = rt.spawn(PingPongActor).unwrap();
|
||||||
|
rt.join_group(p1, "bcast");
|
||||||
|
rt.join_group(p2, "bcast");
|
||||||
|
let broadcaster = rt.spawn(Broadcaster).unwrap();
|
||||||
|
rt.tick();
|
||||||
|
rt.send_to(broadcaster, BroadcastCmd { reply_to: *inbox.addr() }).unwrap();
|
||||||
|
tick_n(&rt, 3);
|
||||||
|
let mut pongs = 0;
|
||||||
|
while inbox.try_recv().is_some() { pongs += 1; }
|
||||||
|
assert!(pongs >= 2, "at least 2 PingPong members replied, got {pongs}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Ask Pattern
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// Ask pattern: basic ask, repeated asks track state, try_recv before/after
|
||||||
|
/// tick, dead actor times out.
|
||||||
|
#[test]
|
||||||
|
fn ask_pattern() {
|
||||||
|
let rt = std_runtime(RuntimeConfig::default());
|
||||||
|
|
||||||
|
// Basic ask
|
||||||
|
let actor = rt.spawn(PingPongActor).unwrap();
|
||||||
|
rt.tick();
|
||||||
|
let pong: Pong = rt.ask(actor, |reply_to| Ping { reply_to })
|
||||||
|
.unwrap().recv_ticking(&rt, 10).unwrap();
|
||||||
|
assert_eq!(pong, Pong);
|
||||||
|
|
||||||
|
// Repeated asks track state
|
||||||
|
let counter = rt.spawn(CounterActor { count: 0 }).unwrap();
|
||||||
|
rt.tick();
|
||||||
|
let c1: Count = rt.ask(counter, |reply_to| Increment { reply_to })
|
||||||
|
.unwrap().recv_ticking(&rt, 10).unwrap();
|
||||||
|
let c2: Count = rt.ask(counter, |reply_to| Increment { reply_to })
|
||||||
|
.unwrap().recv_ticking(&rt, 10).unwrap();
|
||||||
|
let c3: Count = rt.ask(counter, |reply_to| Increment { reply_to })
|
||||||
|
.unwrap().recv_ticking(&rt, 10).unwrap();
|
||||||
|
assert_eq!((c1, c2, c3), (Count(1), Count(2), Count(3)));
|
||||||
|
|
||||||
|
// try_recv: None before tick, Some after
|
||||||
|
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();
|
||||||
|
assert!(ask.try_recv().is_none(), "no response before tick");
|
||||||
|
rt.tick();
|
||||||
|
assert_eq!(ask.try_recv(), Some(Pong));
|
||||||
|
|
||||||
|
// Dead actor → timeout
|
||||||
|
let rt = std_runtime(RuntimeConfig::default());
|
||||||
|
let actor = rt.spawn(PingPongActor).unwrap();
|
||||||
|
rt.tick();
|
||||||
|
rt.stop_actor(actor).unwrap();
|
||||||
|
rt.tick();
|
||||||
|
if let Ok(ask) = rt.ask::<Ping, Pong>(actor, |reply_to| Ping { reply_to }) {
|
||||||
|
assert!(ask.recv_ticking(&rt, 5).is_err(), "timeout with dead actor");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Supervision
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// Restart policies: permanent always restarts, transient only on panic,
|
||||||
|
/// temporary never restarts, meltdown after max_restarts.
|
||||||
|
#[test]
|
||||||
|
fn supervision_restart_policies() {
|
||||||
|
// Permanent child panics → restarted
|
||||||
|
let rt = std_runtime(RuntimeConfig::default());
|
||||||
|
let counter = Arc::new(AtomicUsize::new(0));
|
||||||
|
let counter_c = counter.clone();
|
||||||
|
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||||
|
let sup = Supervisor::new(
|
||||||
|
SupervisorStrategy::OneForOne, 5,
|
||||||
|
vec![ChildSpec::new("worker", RestartPolicy::Permanent, move |ctx| {
|
||||||
|
ctx.spawn(PanicAfterN { trigger: 2, count: 0, counter: counter_c.clone() })
|
||||||
|
})],
|
||||||
|
);
|
||||||
|
let sup_addr = rt.spawn(sup).unwrap();
|
||||||
|
tick_n(&rt, 2);
|
||||||
|
let child = rt.stats().actors.iter()
|
||||||
|
.find(|(a, _)| *a != sup_addr).map(|(a, _)| *a).unwrap();
|
||||||
|
rt.send_to(child, Ping { reply_to: *inbox.addr() }).unwrap();
|
||||||
|
rt.tick();
|
||||||
|
assert_eq!(counter.load(Ordering::SeqCst), 1);
|
||||||
|
rt.send_to(child, Ping { reply_to: *inbox.addr() }).unwrap();
|
||||||
|
tick_n(&rt, 5); // panics, supervisor restarts
|
||||||
|
assert_eq!(rt.stats().workers[0].num_actors, 2, "supervisor + restarted child");
|
||||||
|
|
||||||
|
// Transient stops normally → NOT restarted
|
||||||
|
let rt = std_runtime(RuntimeConfig::default());
|
||||||
|
let sup = Supervisor::new(
|
||||||
|
SupervisorStrategy::OneForOne, 5,
|
||||||
|
vec![ChildSpec::new("worker", RestartPolicy::Transient, |ctx| {
|
||||||
|
ctx.spawn(StopsAfterFirst)
|
||||||
|
})],
|
||||||
|
);
|
||||||
|
let sup_addr = rt.spawn(sup).unwrap();
|
||||||
|
tick_n(&rt, 2);
|
||||||
|
let child = rt.stats().actors.iter()
|
||||||
|
.find(|(a, _)| *a != sup_addr).map(|(a, _)| *a).unwrap();
|
||||||
|
rt.send_to(child, Ping { reply_to: ActorAddress::default() }).unwrap();
|
||||||
|
tick_n(&rt, 4);
|
||||||
|
assert_eq!(rt.stats().workers[0].num_actors, 1, "transient+normal → no restart");
|
||||||
|
|
||||||
|
// Transient panics → restarted
|
||||||
|
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, count: 0, counter: counter_c.clone() })
|
||||||
|
})],
|
||||||
|
);
|
||||||
|
let sup_addr = rt.spawn(sup).unwrap();
|
||||||
|
tick_n(&rt, 2);
|
||||||
|
let child = rt.stats().actors.iter()
|
||||||
|
.find(|(a, _)| *a != sup_addr).map(|(a, _)| *a).unwrap();
|
||||||
|
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||||
|
rt.send_to(child, Ping { reply_to: *inbox.addr() }).unwrap();
|
||||||
|
tick_n(&rt, 5);
|
||||||
|
assert_eq!(rt.stats().workers[0].num_actors, 2, "transient+panic → restarted");
|
||||||
|
|
||||||
|
// Temporary never restarts
|
||||||
|
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();
|
||||||
|
tick_n(&rt, 2);
|
||||||
|
let child = rt.stats().actors.iter()
|
||||||
|
.find(|(a, _)| *a != sup_addr).map(|(a, _)| *a).unwrap();
|
||||||
|
rt.send_to(child, PanicMsg).unwrap();
|
||||||
|
tick_n(&rt, 4);
|
||||||
|
assert_eq!(rt.stats().workers[0].num_actors, 1, "temporary → no restart");
|
||||||
|
|
||||||
|
// Meltdown: max_restarts=2, crash 3 times → supervisor stops
|
||||||
|
let rt = std_runtime(RuntimeConfig::default());
|
||||||
|
let counter = Arc::new(AtomicUsize::new(0));
|
||||||
|
let sup = Supervisor::new(
|
||||||
|
SupervisorStrategy::OneForOne, 2,
|
||||||
|
vec![ChildSpec::new("crasher", RestartPolicy::Permanent, {
|
||||||
|
let c = counter.clone();
|
||||||
|
move |ctx| ctx.spawn(PanicAfterN { trigger: 1, count: 0, counter: c.clone() })
|
||||||
|
})],
|
||||||
|
);
|
||||||
|
let sup_addr = rt.spawn(sup).unwrap();
|
||||||
|
tick_n(&rt, 2);
|
||||||
|
for _ in 0..3 {
|
||||||
|
if let Some((child, _)) = rt.stats().actors.iter()
|
||||||
|
.find(|(a, _)| *a != sup_addr)
|
||||||
|
{
|
||||||
|
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||||
|
let _ = rt.send_to(*child, Ping { reply_to: *inbox.addr() });
|
||||||
|
tick_n(&rt, 5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let sup_alive = rt.stats().actors.iter().any(|(a, _)| *a == sup_addr);
|
||||||
|
assert!(!sup_alive, "supervisor stopped after exceeding max_restarts");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strategies: OneForOne, OneForAll, RestForOne. Stopping supervisor kills children.
|
||||||
|
#[test]
|
||||||
|
fn supervision_strategies() {
|
||||||
|
// OneForOne: only failed child restarted
|
||||||
|
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("ofo_a", PanicAfterN {
|
||||||
|
trigger: 1, count: 0, counter: c.clone(),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
ChildSpec::new("stable", RestartPolicy::Permanent, {
|
||||||
|
let c = counter_b.clone();
|
||||||
|
move |ctx| ctx.spawn_named("ofo_b", CountingPingActor { counter: c.clone() })
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
rt.spawn(sup).unwrap();
|
||||||
|
tick_n(&rt, 2);
|
||||||
|
let child_a = rt.where_is("ofo_a").unwrap();
|
||||||
|
let child_b = rt.where_is("ofo_b").unwrap();
|
||||||
|
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||||
|
rt.send_to(child_a, Ping { reply_to: *inbox.addr() }).unwrap();
|
||||||
|
tick_n(&rt, 5);
|
||||||
|
let child_b_after = rt.where_is("ofo_b").unwrap();
|
||||||
|
assert_eq!(child_b, child_b_after, "child_b unchanged in OneForOne");
|
||||||
|
rt.send_to(child_b, Ping { reply_to: *inbox.addr() }).unwrap();
|
||||||
|
rt.tick();
|
||||||
|
assert!(counter_b.load(Ordering::SeqCst) >= 1, "child_b still processing");
|
||||||
|
|
||||||
|
// OneForAll: all children restarted
|
||||||
|
let rt = std_runtime(RuntimeConfig::default());
|
||||||
|
let sup = Supervisor::new(
|
||||||
|
SupervisorStrategy::OneForAll, 5,
|
||||||
|
vec![
|
||||||
|
ChildSpec::new("a", RestartPolicy::Permanent, {
|
||||||
|
let c = Arc::new(AtomicUsize::new(0));
|
||||||
|
move |ctx| ctx.spawn_named("ofa_a", PanicAfterN {
|
||||||
|
trigger: 1, count: 0, counter: c.clone(),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
ChildSpec::new("b", RestartPolicy::Permanent, {
|
||||||
|
let c = Arc::new(AtomicUsize::new(0));
|
||||||
|
move |ctx| ctx.spawn_named("ofa_b", CountingPingActor { counter: c.clone() })
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
rt.spawn(sup).unwrap();
|
||||||
|
tick_n(&rt, 2);
|
||||||
|
let old_b = rt.where_is("ofa_b").unwrap();
|
||||||
|
let child_a = rt.where_is("ofa_a").unwrap();
|
||||||
|
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||||
|
rt.send_to(child_a, Ping { reply_to: *inbox.addr() }).unwrap();
|
||||||
|
tick_n(&rt, 8);
|
||||||
|
let new_b = rt.where_is("ofa_b").expect("ofa_b re-registered");
|
||||||
|
assert_ne!(old_b, new_b, "child_b restarted in OneForAll");
|
||||||
|
|
||||||
|
// RestForOne: failed child + later children restarted, earlier unaffected
|
||||||
|
let rt = std_runtime(RuntimeConfig::default());
|
||||||
|
let sup = Supervisor::new(
|
||||||
|
SupervisorStrategy::RestForOne, 5,
|
||||||
|
vec![
|
||||||
|
ChildSpec::new("a", RestartPolicy::Permanent, {
|
||||||
|
let c = Arc::new(AtomicUsize::new(0));
|
||||||
|
move |ctx| ctx.spawn_named("rfo_a", CountingPingActor { counter: c.clone() })
|
||||||
|
}),
|
||||||
|
ChildSpec::new("b", RestartPolicy::Permanent, {
|
||||||
|
let c = Arc::new(AtomicUsize::new(0));
|
||||||
|
move |ctx| ctx.spawn_named("rfo_b", PanicAfterN {
|
||||||
|
trigger: 1, count: 0, counter: c.clone(),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
ChildSpec::new("c", RestartPolicy::Permanent, {
|
||||||
|
let c = Arc::new(AtomicUsize::new(0));
|
||||||
|
move |ctx| ctx.spawn_named("rfo_c", CountingPingActor { counter: c.clone() })
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
rt.spawn(sup).unwrap();
|
||||||
|
tick_n(&rt, 2);
|
||||||
|
let old_a = rt.where_is("rfo_a").unwrap();
|
||||||
|
let old_c = rt.where_is("rfo_c").unwrap();
|
||||||
|
let child_b = rt.where_is("rfo_b").unwrap();
|
||||||
|
let inbox = rt.new_inbox::<Pong>().unwrap();
|
||||||
|
rt.send_to(child_b, Ping { reply_to: *inbox.addr() }).unwrap();
|
||||||
|
tick_n(&rt, 8);
|
||||||
|
let new_a = rt.where_is("rfo_a").unwrap();
|
||||||
|
let new_c = rt.where_is("rfo_c").expect("rfo_c re-registered");
|
||||||
|
assert_eq!(old_a, new_a, "child_a unchanged in RestForOne");
|
||||||
|
assert_ne!(old_c, new_c, "child_c restarted in RestForOne");
|
||||||
|
|
||||||
|
// Stopping supervisor 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();
|
||||||
|
tick_n(&rt, 2);
|
||||||
|
assert_eq!(rt.stats().workers[0].num_actors, 3);
|
||||||
|
rt.stop_actor(sup_addr).unwrap();
|
||||||
|
tick_n(&rt, 5);
|
||||||
|
assert_eq!(rt.stats().workers[0].num_actors, 0, "stopping supervisor kills children");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// handle_down dispatch and ctx.stop_actor from handler.
|
||||||
|
#[test]
|
||||||
|
fn handle_down_dispatch() {
|
||||||
|
// ctx.stop_actor from handler 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();
|
||||||
|
rt.send_to(stopper, StopCmd { target }).unwrap();
|
||||||
|
tick_n(&rt, 4);
|
||||||
|
assert!(rt.send_to(target, Ping { reply_to: ActorAddress::default() }).is_err(),
|
||||||
|
"target stopped by ctx.stop_actor");
|
||||||
|
assert!(rt.send_to(stopper, StopCmd { target }).is_ok(), "stopper still alive");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Router
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// Router distributes work: round-robin is even, broadcast hits all, random
|
||||||
|
/// uses multiple workers. Dead workers replaced. Stop router kills workers.
|
||||||
|
/// Meltdown after max restarts.
|
||||||
|
#[test]
|
||||||
|
fn router_work_distribution() {
|
||||||
|
// Round-robin: 3 workers, 6 msgs → 2 each
|
||||||
|
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();
|
||||||
|
for i in 0..6 {
|
||||||
|
rt.send_to(router_addr, Work(i)).unwrap();
|
||||||
|
}
|
||||||
|
tick_n(&rt, 3);
|
||||||
|
let data = collected.lock().unwrap();
|
||||||
|
assert_eq!(data.len(), 6);
|
||||||
|
let mut per_worker = std::collections::HashMap::new();
|
||||||
|
for (addr, _) in data.iter() {
|
||||||
|
*per_worker.entry(*addr).or_insert(0usize) += 1;
|
||||||
|
}
|
||||||
|
assert_eq!(per_worker.len(), 3, "3 distinct workers");
|
||||||
|
for count in per_worker.values() {
|
||||||
|
assert_eq!(*count, 2, "each worker gets exactly 2");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast: 5 msgs to 3 workers → 15 total
|
||||||
|
let rt = std_runtime(RuntimeConfig::default());
|
||||||
|
let total = Arc::new(AtomicUsize::new(0));
|
||||||
|
struct BCounter(Arc<AtomicUsize>);
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct BPing;
|
||||||
|
impl ActorInterface for BCounter {
|
||||||
|
type Incoming = BPing;
|
||||||
|
type Response = ();
|
||||||
|
fn handle(&mut self, _ctx: &Ctx, _msg: BPing) {
|
||||||
|
self.0.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let t = total.clone();
|
||||||
|
let router = Router::<BPing>::new(
|
||||||
|
RoutingStrategy::Broadcast, 3,
|
||||||
|
move |ctx| ctx.spawn(BCounter(t.clone())), 10,
|
||||||
|
);
|
||||||
|
let router_addr = rt.spawn(router).unwrap();
|
||||||
|
rt.tick();
|
||||||
|
for _ in 0..5 {
|
||||||
|
rt.send_to(router_addr, BPing).unwrap();
|
||||||
|
}
|
||||||
|
tick_n(&rt, 3);
|
||||||
|
assert_eq!(total.load(Ordering::Relaxed), 15, "5 broadcasts × 3 workers = 15");
|
||||||
|
|
||||||
|
// Random: 30 msgs → at least 2 workers used
|
||||||
|
let rt = std_runtime(RuntimeConfig::default());
|
||||||
|
let rcollected = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||||
|
struct RCollector(Arc<std::sync::Mutex<Vec<ActorAddress>>>);
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct RWork;
|
||||||
|
impl ActorInterface for RCollector {
|
||||||
|
type Incoming = RWork;
|
||||||
|
type Response = ();
|
||||||
|
fn handle(&mut self, ctx: &Ctx, _msg: RWork) {
|
||||||
|
self.0.lock().unwrap().push(ctx.self_addr());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let c = rcollected.clone();
|
||||||
|
let router = Router::<RWork>::new(
|
||||||
|
RoutingStrategy::Random, 3,
|
||||||
|
move |ctx| ctx.spawn(RCollector(c.clone())), 10,
|
||||||
|
);
|
||||||
|
let router_addr = rt.spawn(router).unwrap();
|
||||||
|
rt.tick();
|
||||||
|
for _ in 0..30 {
|
||||||
|
rt.send_to(router_addr, RWork).unwrap();
|
||||||
|
}
|
||||||
|
tick_n(&rt, 3);
|
||||||
|
let data = rcollected.lock().unwrap();
|
||||||
|
let unique: std::collections::HashSet<_> = data.iter().collect();
|
||||||
|
assert!(unique.len() >= 2, "random uses at least 2 workers");
|
||||||
|
|
||||||
|
// Dead worker replaced
|
||||||
|
let rt = std_runtime(RuntimeConfig::default());
|
||||||
|
let spawn_count = Arc::new(AtomicUsize::new(0));
|
||||||
|
struct PanicOnFirst { first: bool }
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct DWork;
|
||||||
|
impl ActorInterface for PanicOnFirst {
|
||||||
|
type Incoming = DWork;
|
||||||
|
type Response = ();
|
||||||
|
fn handle(&mut self, _ctx: &Ctx, _msg: DWork) {
|
||||||
|
if self.first { self.first = false; panic!("first message panic"); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let sc = spawn_count.clone();
|
||||||
|
let router = Router::<DWork>::new(
|
||||||
|
RoutingStrategy::RoundRobin, 3,
|
||||||
|
move |ctx| { sc.fetch_add(1, Ordering::Relaxed); ctx.spawn(PanicOnFirst { first: sc.load(Ordering::Relaxed) == 1 }) },
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
let router_addr = rt.spawn(router).unwrap();
|
||||||
|
rt.tick();
|
||||||
|
rt.send_to(router_addr, DWork).unwrap();
|
||||||
|
tick_n(&rt, 5);
|
||||||
|
assert!(spawn_count.load(Ordering::Relaxed) >= 4, "replacement spawned");
|
||||||
|
|
||||||
|
// Meltdown: max_restarts=2
|
||||||
|
let rt = std_runtime(RuntimeConfig::default());
|
||||||
|
struct AlwaysPanics;
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct MWork;
|
||||||
|
impl ActorInterface for AlwaysPanics {
|
||||||
|
type Incoming = MWork;
|
||||||
|
type Response = ();
|
||||||
|
fn handle(&mut self, _ctx: &Ctx, _msg: MWork) { panic!("always"); }
|
||||||
|
}
|
||||||
|
let router = Router::<MWork>::new(
|
||||||
|
RoutingStrategy::RoundRobin, 1,
|
||||||
|
|ctx| ctx.spawn(AlwaysPanics), 2,
|
||||||
|
);
|
||||||
|
let router_addr = rt.spawn(router).unwrap();
|
||||||
|
rt.tick();
|
||||||
|
for _ in 0..3 {
|
||||||
|
rt.send_to(router_addr, MWork).unwrap();
|
||||||
|
tick_n(&rt, 5);
|
||||||
|
}
|
||||||
|
tick_n(&rt, 5);
|
||||||
|
assert_eq!(rt.stats().workers[0].num_actors, 0, "router melted down");
|
||||||
|
|
||||||
|
// Stop router kills workers
|
||||||
|
let rt = std_runtime(RuntimeConfig::default());
|
||||||
|
struct Dummy;
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct SWork;
|
||||||
|
impl ActorInterface for Dummy {
|
||||||
|
type Incoming = SWork;
|
||||||
|
type Response = ();
|
||||||
|
fn handle(&mut self, _ctx: &Ctx, _msg: SWork) {}
|
||||||
|
}
|
||||||
|
let router = Router::<SWork>::new(
|
||||||
|
RoutingStrategy::RoundRobin, 3,
|
||||||
|
|ctx| ctx.spawn(Dummy), 10,
|
||||||
|
);
|
||||||
|
let router_addr = rt.spawn(router).unwrap();
|
||||||
|
rt.tick();
|
||||||
|
assert_eq!(rt.stats().workers[0].num_actors, 4);
|
||||||
|
rt.stop_actor(router_addr).unwrap();
|
||||||
|
tick_n(&rt, 5);
|
||||||
|
assert_eq!(rt.stats().workers[0].num_actors, 0, "stop router kills workers");
|
||||||
|
}
|
||||||
|
|
@ -1,378 +0,0 @@
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use swactor::actor::{ActorAddress, ActorExited, ActorInterface, ExitReason};
|
|
||||||
use swactor::runtime::{Ctx, Runtime, RuntimeConfig};
|
|
||||||
|
|
||||||
// ── Actors ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// An actor that panics when it receives PanicMsg.
|
|
||||||
struct PanicOnCommand;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct PanicMsg;
|
|
||||||
|
|
||||||
impl ActorInterface for PanicOnCommand {
|
|
||||||
type Incoming = PanicMsg;
|
|
||||||
type Response = ();
|
|
||||||
fn handle(&mut self, _ctx: &Ctx, _msg: PanicMsg) {
|
|
||||||
panic!("deliberate panic for test");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An actor that watches targets and counts exit notifications.
|
|
||||||
struct ExitWatcher {
|
|
||||||
exit_count: Arc<AtomicUsize>,
|
|
||||||
last_reason: Arc<std::sync::Mutex<Option<ExitReason>>>,
|
|
||||||
last_addr: Arc<std::sync::Mutex<Option<ActorAddress>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
enum WatcherCmd {
|
|
||||||
WatchThis(ActorAddress),
|
|
||||||
UnwatchThis(ActorAddress),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ActorInterface for ExitWatcher {
|
|
||||||
type Incoming = WatcherCmd;
|
|
||||||
type Response = ();
|
|
||||||
fn handle(&mut self, ctx: &Ctx, msg: WatcherCmd) {
|
|
||||||
match msg {
|
|
||||||
WatcherCmd::WatchThis(target) => {
|
|
||||||
ctx.watch(target);
|
|
||||||
}
|
|
||||||
WatcherCmd::UnwatchThis(target) => {
|
|
||||||
ctx.unwatch(target);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn on_actor_exit(&mut self, _ctx: &Ctx, exited: ActorExited) {
|
|
||||||
self.exit_count.fetch_add(1, Ordering::SeqCst);
|
|
||||||
*self.last_reason.lock().unwrap() = Some(exited.reason);
|
|
||||||
*self.last_addr.lock().unwrap() = Some(exited.addr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ExitWatcher {
|
|
||||||
fn new() -> (Self, WatcherState) {
|
|
||||||
let exit_count = Arc::new(AtomicUsize::new(0));
|
|
||||||
let last_reason = Arc::new(std::sync::Mutex::new(None));
|
|
||||||
let last_addr = Arc::new(std::sync::Mutex::new(None));
|
|
||||||
let state = WatcherState {
|
|
||||||
exit_count: exit_count.clone(),
|
|
||||||
last_reason: last_reason.clone(),
|
|
||||||
last_addr: last_addr.clone(),
|
|
||||||
};
|
|
||||||
(
|
|
||||||
ExitWatcher {
|
|
||||||
exit_count,
|
|
||||||
last_reason,
|
|
||||||
last_addr,
|
|
||||||
},
|
|
||||||
state,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Shared state for inspecting what ExitWatcher observed.
|
|
||||||
struct WatcherState {
|
|
||||||
exit_count: Arc<AtomicUsize>,
|
|
||||||
last_reason: Arc<std::sync::Mutex<Option<ExitReason>>>,
|
|
||||||
last_addr: Arc<std::sync::Mutex<Option<ActorAddress>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WatcherState {
|
|
||||||
fn count(&self) -> usize {
|
|
||||||
self.exit_count.load(Ordering::SeqCst)
|
|
||||||
}
|
|
||||||
fn last_reason(&self) -> Option<ExitReason> {
|
|
||||||
self.last_reason.lock().unwrap().clone()
|
|
||||||
}
|
|
||||||
fn last_addr(&self) -> Option<ActorAddress> {
|
|
||||||
*self.last_addr.lock().unwrap()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A silent actor that does nothing (for targets that shouldn't panic).
|
|
||||||
struct Sleeper;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct Noop;
|
|
||||||
|
|
||||||
impl ActorInterface for Sleeper {
|
|
||||||
type Incoming = Noop;
|
|
||||||
type Response = ();
|
|
||||||
fn handle(&mut self, _ctx: &Ctx, _msg: Noop) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helper ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
fn tick_n(rt: &Runtime, n: usize) {
|
|
||||||
for _ in 0..n {
|
|
||||||
rt.tick();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn single_thread_config() -> RuntimeConfig {
|
|
||||||
RuntimeConfig {
|
|
||||||
num_threads: 1,
|
|
||||||
..RuntimeConfig::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tests ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Given a watcher and a target actor,
|
|
||||||
/// when the target panics,
|
|
||||||
/// 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 (watcher_actor, state) = ExitWatcher::new();
|
|
||||||
|
|
||||||
let target = rt.spawn(PanicOnCommand).unwrap();
|
|
||||||
let watcher = rt.spawn(watcher_actor).unwrap();
|
|
||||||
|
|
||||||
// Tell watcher to watch the target
|
|
||||||
rt.send_to(watcher, WatcherCmd::WatchThis(target)).unwrap();
|
|
||||||
tick_n(&rt, 3);
|
|
||||||
|
|
||||||
// Kill the target
|
|
||||||
rt.send_to(target, PanicMsg).unwrap();
|
|
||||||
tick_n(&rt, 5);
|
|
||||||
|
|
||||||
assert_eq!(state.count(), 1, "watcher should have received exactly one ActorExited");
|
|
||||||
assert_eq!(state.last_reason(), Some(ExitReason::Panicked));
|
|
||||||
assert_eq!(state.last_addr(), Some(target));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Given a watcher that watches then unwatches a target,
|
|
||||||
/// when the target panics,
|
|
||||||
/// then the watcher receives NO notification.
|
|
||||||
#[test]
|
|
||||||
fn unwatch_prevents_notification() {
|
|
||||||
let rt = Runtime::new(single_thread_config());
|
|
||||||
let (watcher_actor, state) = ExitWatcher::new();
|
|
||||||
|
|
||||||
let target = rt.spawn(PanicOnCommand).unwrap();
|
|
||||||
let watcher = rt.spawn(watcher_actor).unwrap();
|
|
||||||
|
|
||||||
// Watch
|
|
||||||
rt.send_to(watcher, WatcherCmd::WatchThis(target)).unwrap();
|
|
||||||
tick_n(&rt, 3);
|
|
||||||
|
|
||||||
// Unwatch
|
|
||||||
rt.send_to(watcher, WatcherCmd::UnwatchThis(target)).unwrap();
|
|
||||||
tick_n(&rt, 3);
|
|
||||||
|
|
||||||
// Kill target
|
|
||||||
rt.send_to(target, PanicMsg).unwrap();
|
|
||||||
tick_n(&rt, 5);
|
|
||||||
|
|
||||||
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 target = rt.spawn(PanicOnCommand).unwrap();
|
|
||||||
let (watcher_actor, _state) = ExitWatcher::new();
|
|
||||||
let watcher = rt.spawn(watcher_actor).unwrap();
|
|
||||||
|
|
||||||
// Watch
|
|
||||||
rt.send_to(watcher, WatcherCmd::WatchThis(target)).unwrap();
|
|
||||||
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 target — should not crash
|
|
||||||
rt2.send_to(target2, PanicMsg).unwrap();
|
|
||||||
tick_n(&rt2, 5);
|
|
||||||
|
|
||||||
// If we got here, no crash.
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Given a watcher that calls watch() twice on the same target,
|
|
||||||
/// when the target panics,
|
|
||||||
/// then the watcher receives exactly one notification.
|
|
||||||
#[test]
|
|
||||||
fn idempotent_watch_delivers_one_notification() {
|
|
||||||
let rt = Runtime::new(single_thread_config());
|
|
||||||
let (watcher_actor, state) = ExitWatcher::new();
|
|
||||||
|
|
||||||
let target = rt.spawn(PanicOnCommand).unwrap();
|
|
||||||
let watcher = rt.spawn(watcher_actor).unwrap();
|
|
||||||
|
|
||||||
// Watch twice
|
|
||||||
rt.send_to(watcher, WatcherCmd::WatchThis(target)).unwrap();
|
|
||||||
tick_n(&rt, 3);
|
|
||||||
rt.send_to(watcher, WatcherCmd::WatchThis(target)).unwrap();
|
|
||||||
tick_n(&rt, 3);
|
|
||||||
|
|
||||||
// Kill target
|
|
||||||
rt.send_to(target, PanicMsg).unwrap();
|
|
||||||
tick_n(&rt, 5);
|
|
||||||
|
|
||||||
assert_eq!(state.count(), 1, "double watch should produce exactly one notification");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Given multiple watchers on the same target,
|
|
||||||
/// when the target panics,
|
|
||||||
/// then all watchers receive the notification.
|
|
||||||
#[test]
|
|
||||||
fn multiple_watchers_all_notified() {
|
|
||||||
let rt = Runtime::new(single_thread_config());
|
|
||||||
let (w1_actor, s1) = ExitWatcher::new();
|
|
||||||
let (w2_actor, s2) = ExitWatcher::new();
|
|
||||||
let (w3_actor, s3) = ExitWatcher::new();
|
|
||||||
|
|
||||||
let target = rt.spawn(PanicOnCommand).unwrap();
|
|
||||||
let w1 = rt.spawn(w1_actor).unwrap();
|
|
||||||
let w2 = rt.spawn(w2_actor).unwrap();
|
|
||||||
let w3 = rt.spawn(w3_actor).unwrap();
|
|
||||||
|
|
||||||
rt.send_to(w1, WatcherCmd::WatchThis(target)).unwrap();
|
|
||||||
rt.send_to(w2, WatcherCmd::WatchThis(target)).unwrap();
|
|
||||||
rt.send_to(w3, WatcherCmd::WatchThis(target)).unwrap();
|
|
||||||
tick_n(&rt, 3);
|
|
||||||
|
|
||||||
rt.send_to(target, PanicMsg).unwrap();
|
|
||||||
tick_n(&rt, 5);
|
|
||||||
|
|
||||||
assert_eq!(s1.count(), 1, "watcher 1 should be notified");
|
|
||||||
assert_eq!(s2.count(), 1, "watcher 2 should be notified");
|
|
||||||
assert_eq!(s3.count(), 1, "watcher 3 should be notified");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Self-watch doesn't crash the runtime.
|
|
||||||
#[test]
|
|
||||||
fn self_watch_does_not_crash() {
|
|
||||||
let rt = Runtime::new(single_thread_config());
|
|
||||||
let (watcher_actor, _state) = ExitWatcher::new();
|
|
||||||
|
|
||||||
let actor = rt.spawn(watcher_actor).unwrap();
|
|
||||||
rt.send_to(actor, WatcherCmd::WatchThis(actor)).unwrap();
|
|
||||||
tick_n(&rt, 5);
|
|
||||||
|
|
||||||
// No crash = pass
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Runtime-level watch (outside actor context) delivers notification.
|
|
||||||
#[test]
|
|
||||||
fn runtime_level_watch_delivers_notification() {
|
|
||||||
let rt = Runtime::new(single_thread_config());
|
|
||||||
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();
|
|
||||||
tick_n(&rt, 5);
|
|
||||||
|
|
||||||
assert_eq!(state.count(), 1, "runtime-level watch should deliver 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 spawned = Arc::new(AtomicUsize::new(0));
|
|
||||||
|
|
||||||
struct Supervisor {
|
|
||||||
spawned_count: Arc<AtomicUsize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
enum SupervisorMsg {
|
|
||||||
WatchThis(ActorAddress),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ActorInterface for Supervisor {
|
|
||||||
type Incoming = SupervisorMsg;
|
|
||||||
type Response = ();
|
|
||||||
fn handle(&mut self, ctx: &Ctx, msg: SupervisorMsg) {
|
|
||||||
match msg {
|
|
||||||
SupervisorMsg::WatchThis(target) => ctx.watch(target),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn on_actor_exit(&mut self, ctx: &Ctx, _exited: ActorExited) {
|
|
||||||
// React: spawn a replacement
|
|
||||||
let replacement = ctx.spawn(Sleeper).unwrap();
|
|
||||||
let _ = replacement;
|
|
||||||
self.spawned_count.fetch_add(1, Ordering::SeqCst);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let target = rt.spawn(PanicOnCommand).unwrap();
|
|
||||||
let sup = rt.spawn(Supervisor { spawned_count: spawned.clone() }).unwrap();
|
|
||||||
|
|
||||||
rt.send_to(sup, SupervisorMsg::WatchThis(target)).unwrap();
|
|
||||||
tick_n(&rt, 3);
|
|
||||||
|
|
||||||
rt.send_to(target, PanicMsg).unwrap();
|
|
||||||
tick_n(&rt, 5);
|
|
||||||
|
|
||||||
assert_eq!(spawned.load(Ordering::SeqCst), 1, "supervisor should have spawned a replacement");
|
|
||||||
}
|
|
||||||
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