Files
KPN/include/kpn/web_debug.hpp
T
dtourolle a8cfe7300a fix: a lossless fanout, a node that starts awake, and the instrumentation that found them
Three changes from one debugging session on the intermittent wedge, kept
together because the instrumentation is what made the other two findable.

**Fanout was never made lossless.** 6595e6e made node outputs lossless and
28e0667 stopped them parking a worker; FanoutNode was in neither and kept
`catch (ChannelOverflowError&) {}` per output. Whichever branch fell behind
lost items, silently, by an amount that depended on timing — so two runs of
the same input could disagree. deliver() now retries each output
independently until it is taken, rechecking stop_flag_ every pass so
teardown cannot hang on a full output. A fanout owns a private thread, so
waiting costs no scheduler worker.

**A node could start with a wake already outstanding.** start() enables the
input channel several statements before it installs the push callback, and
StaticNetwork starts nodes sources-first, so an upstream node is already
firing into the gap. A push landing there is accepted by the ring but wakes
nobody: push_callback_ fires only on the empty→non-empty transition, and at
that instant the callback is null. Every later push sees a non-empty ring
and stays silent, so the node is never submitted. Asking on_input_ready()
once at the end of start() converts the missed edge into a state check.

The signature is distinctive — zero items delivered, not a stall partway.
Under `ctest -j4` on a loaded machine it reproduced 7 times in 24 and never
in 10 unloaded runs, which is almost certainly the "~1 run in 20" hang
28e0667 recorded as known-incomplete.

**NodeSnapshot now carries scheduling state and a true exec total.** queued
and wake_pending make the 9c5ce5f invariant observable at runtime; it could
previously only be inspected in a debugger, and the bug does not reproduce
under one. total_exec_us is a real sum — frames × ema_exec_us tracks the
tail of a run, not the whole of it, and diverges badly on a workload whose
per-frame cost varies. Both are exposed over the web debug JSON so a wedged
pipeline can be interrogated without attaching to it.
2026-08-05 12:40:03 +02:00

423 lines
16 KiB
C++

#pragma once
// Only compiled when KPN_WEB_DEBUG is defined. network.hpp includes this conditionally.
#include "diagnostics.hpp"
#include <httplib.h>
#include <atomic>
#include <sstream>
#include <string>
#include <thread>
#include <vector>
namespace kpn::web_debug {
// ── Minimal JSON serialiser ───────────────────────────────────────────────────
static std::string escape_json(const std::string& s) {
std::string out;
out.reserve(s.size());
for (char c : s) {
if (c == '"') out += "\\\"";
else if (c == '\\') out += "\\\\";
else if (c == '\n') out += "\\n";
else if (c == '\r') out += "\\r";
else if (c == '\t') out += "\\t";
else out += c;
}
return out;
}
// Parse "src_name:N → dst_name:M" into {src_name, dst_name}.
// Stored separately so the browser doesn't need to regex-parse a UTF-8 arrow.
static std::pair<std::string,std::string> parse_edge_name(const std::string& name) {
// Format: "<src>:<idx> → <dst>:<idx>"
auto arrow = name.find(" \xe2\x86\x92 "); // UTF-8 for →
if (arrow == std::string::npos) return {name, name};
std::string src_part = name.substr(0, arrow);
std::string dst_part = name.substr(arrow + 5); // " → " = 5 bytes (space + 3-byte arrow + space)
// Strip ":N" index suffix
auto sc1 = src_part.rfind(':');
auto sc2 = dst_part.rfind(':');
if (sc1 != std::string::npos) src_part = src_part.substr(0, sc1);
if (sc2 != std::string::npos) dst_part = dst_part.substr(0, sc2);
return {src_part, dst_part};
}
static std::string to_json(const std::vector<NodeSnapshot>& nodes,
const std::vector<ChannelSnapshot>& channels,
const std::vector<ResourceSnapshot>& resources = {},
double elapsed_s = 0.0,
const std::vector<PoolSnapshot>& pools = {}) {
std::ostringstream o;
o << std::fixed;
o.precision(2);
o << "{\"nodes\":[";
for (std::size_t i = 0; i < nodes.size(); ++i) {
const auto& n = nodes[i];
if (i) o << ',';
o << "{\"id\":\"" << escape_json(n.name) << "\""
<< ",\"frames\":" << n.frames_processed
<< ",\"ema_exec_ms\":" << n.ema_exec_ms
<< ",\"max_exec_ms\":" << n.max_exec_ms
<< ",\"blocked_ms\":" << n.total_blocked_ms
<< ",\"fps\":" << n.throughput_fps
<< ",\"total_cpu_ms\":" << n.total_cpu_ms
<< ",\"cpu_util_pct\":" << n.cpu_util_pct
// Scheduling state — lets a WEDGED pipeline be interrogated over HTTP
// without a debugger, which matters because the lost-wake bug does not
// reproduce under one. See NodeSnapshot for how to read the pair.
<< ",\"queued\":" << (n.queued ? "true" : "false")
<< ",\"wake_pending\":" << (n.wake_pending ? "true" : "false")
<< "}";
}
o << "],\"edges\":[";
for (std::size_t i = 0; i < channels.size(); ++i) {
const auto& c = channels[i];
if (i) o << ',';
auto [src, dst] = parse_edge_name(c.name);
o << "{\"name\":\"" << escape_json(c.name) << "\""
<< ",\"source\":\"" << escape_json(src) << "\""
<< ",\"target\":\"" << escape_json(dst) << "\""
<< ",\"capacity\":" << c.capacity
<< ",\"current\":" << c.current_fill
<< ",\"fill_pct\":" << c.fill_pct()
<< ",\"peak_pct\":" << c.peak_pct()
<< ",\"pushes\":" << c.pushes
<< ",\"drops\":" << c.drops
<< ",\"overflows\":" << c.overflows
<< ",\"item_bytes\":" << c.item_bytes
<< ",\"bw_mbs\":" << c.bandwidth_mbs(elapsed_s)
<< "}";
}
o << "],\"resources\":[";
for (std::size_t i = 0; i < resources.size(); ++i) {
const auto& r = resources[i];
if (i) o << ',';
o << "{\"name\":\"" << escape_json(r.name) << "\""
<< ",\"acquisitions\":" << r.acquisitions
<< ",\"avg_wait_ms\":" << r.avg_wait_ms
<< ",\"peak_waiters\":" << r.peak_waiters
<< ",\"current_waiters\":" << r.current_waiters
<< ",\"held\":" << (r.held ? "true" : "false")
<< "}";
}
o << "],\"pools\":[";
for (std::size_t i = 0; i < pools.size(); ++i) {
const auto& p = pools[i];
if (i) o << ',';
double in_rate = elapsed_s > 0.0 ? p.tasks_submitted / elapsed_s : 0.0;
double out_rate = elapsed_s > 0.0 ? p.tasks_completed / elapsed_s : 0.0;
o << "{\"name\":\"" << escape_json(p.name) << "\""
<< ",\"thread_count\":" << p.thread_count
<< ",\"queue_depth\":" << p.queue_depth
<< ",\"active_count\":" << p.active_count
<< ",\"in_rate\":" << in_rate
<< ",\"out_rate\":" << out_rate
<< "}";
}
o << "]}";
return o.str();
}
// ── Embedded single-page HTML ─────────────────────────────────────────────────
static const char* HTML = R"html(<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>KPN++ Web Debug</title>
<style>
body { margin: 0; background: #1a1a2e; color: #eee; font-family: monospace; }
#header { padding: 12px 20px; background: #16213e; border-bottom: 1px solid #0f3460; }
#header h1 { margin: 0; font-size: 18px; color: #e94560; }
#header span { font-size: 12px; color: #888; margin-left: 16px; }
#graph { width: 100vw; height: calc(100vh - 50px); }
.node circle { stroke: #fff; stroke-width: 1.5px; }
.node text { font-size: 11px; fill: #eee; pointer-events: none; text-anchor: middle; }
.node .stats { font-size: 9px; fill: #aaa; }
.link { fill: none; stroke-width: 2px; }
.link-label { font-size: 9px; fill: #ccc; }
.arrowhead { fill: #888; }
#tooltip {
position: absolute; background: #0f3460; border: 1px solid #e94560;
border-radius: 4px; padding: 8px 12px; font-size: 11px; pointer-events: none;
display: none; white-space: pre; line-height: 1.6;
}
#resources {
position: absolute; bottom: 12px; right: 12px;
background: #16213e; border: 1px solid #0f3460; border-radius: 4px;
padding: 8px 12px; font-size: 10px; min-width: 220px;
display: none;
}
#resources h2 { margin: 0 0 6px; font-size: 11px; color: #e94560; }
.res-row { display: flex; justify-content: space-between; gap: 12px; margin-top: 3px; }
.res-name { color: #eee; }
.res-held-y { color: #e94560; }
.res-held-n { color: #4CAF50; }
</style>
</head>
<body>
<div id="header"><h1>KPN++ Web Debug</h1><span id="status">connecting...</span></div>
<svg id="graph"></svg>
<div id="tooltip"></div>
<div id="resources"><h2>Shared Resources</h2><div id="res-list"></div></div>
<script src="https://d3js.org/d3.v7.min.js"></script>
<script>
const nodeRadius = 30;
const svg = d3.select('#graph');
const width = () => window.innerWidth;
const height = () => window.innerHeight - 50;
// Arrow marker defs
const defs = svg.append('defs');
['green','#f0c040','#e94560'].forEach((col, i) => {
defs.append('marker')
.attr('id', 'arrow' + i)
.attr('viewBox', '0 -5 10 10').attr('refX', 10).attr('refY', 0)
.attr('markerWidth', 6).attr('markerHeight', 6).attr('orient', 'auto')
.append('path').attr('d', 'M0,-5L10,0L0,5').attr('fill', col);
});
const g = svg.append('g');
svg.call(d3.zoom().on('zoom', e => g.attr('transform', e.transform)));
let sim, linkSel, nodeSel, labelSel, edgeLabelSel, edgeLabelBwSel;
let nodes = [], links = [];
function edgeColor(fill_pct) {
if (fill_pct >= 80) return '#e94560';
if (fill_pct >= 50) return '#f0c040';
return '#4CAF50';
}
function edgeArrow(fill_pct) {
if (fill_pct >= 80) return 'url(#arrow2)';
if (fill_pct >= 50) return 'url(#arrow1)';
return 'url(#arrow0)';
}
function nodeColor(ema) {
if (ema > 100) return '#e94560';
if (ema > 50) return '#e07040';
if (ema > 10) return '#f0c040';
return '#4CAF50';
}
function init(data) {
nodes = data.nodes.map(n => ({ ...n, x: width()/2, y: height()/2 }));
const nodeById = Object.fromEntries(nodes.map(n => [n.id, n]));
links = data.edges.map(e => ({
...e,
source: nodeById[e.source],
target: nodeById[e.target],
})).filter(e => e.source && e.target);
sim = d3.forceSimulation(nodes)
.force('link', d3.forceLink(links).distance(160).strength(0.5))
.force('charge', d3.forceManyBody().strength(-400))
.force('center', d3.forceCenter(width()/2, height()/2))
.force('collide', d3.forceCollide(nodeRadius + 20))
.on('tick', ticked);
// Links
linkSel = g.append('g').selectAll('line').data(links).join('line')
.attr('class', 'link')
.attr('stroke', d => edgeColor(d.fill_pct))
.attr('marker-end', d => edgeArrow(d.fill_pct));
edgeLabelSel = g.append('g').selectAll('text').data(links).join('text')
.attr('class', 'link-label')
.text(d => `${d.fill_pct.toFixed(0)}%`);
edgeLabelBwSel = g.append('g').selectAll('text').data(links).join('text')
.attr('class', 'link-label')
.text(d => `${d.bw_mbs.toFixed(1)} MB/s`);
// Nodes
const nodeG = g.append('g').selectAll('g').data(nodes).join('g')
.attr('class', 'node')
.call(d3.drag()
.on('start', (e,d) => { if (!e.active) sim.alphaTarget(0.3).restart(); d.fx=d.x; d.fy=d.y; })
.on('drag', (e,d) => { d.fx=e.x; d.fy=e.y; })
.on('end', (e,d) => { if (!e.active) sim.alphaTarget(0); d.fx=null; d.fy=null; }));
nodeG.append('circle').attr('r', nodeRadius).attr('fill', d => nodeColor(d.ema_exec_ms));
nodeG.append('text').attr('dy', 4).text(d => d.id);
nodeG.append('text').attr('class', 'stats').attr('dy', 18)
.text(d => `${d.ema_exec_ms.toFixed(1)}ms ${d.fps.toFixed(1)}fps`);
nodeSel = nodeG;
renderResources(data.resources);
// Tooltips
const tip = d3.select('#tooltip');
nodeG.on('mousemove', (e, d) => {
tip.style('display','block')
.style('left', (e.pageX+12)+'px').style('top', (e.pageY+12)+'px')
.text(
`node: ${d.id}\nframes: ${d.frames}\nexec (ema): ${d.ema_exec_ms.toFixed(2)} ms` +
`\nexec (max): ${d.max_exec_ms.toFixed(2)} ms\nblocked: ${d.blocked_ms.toFixed(2)} ms` +
`\nfps: ${d.fps.toFixed(2)}\ncpu total: ${d.total_cpu_ms.toFixed(1)} ms` +
`\ncpu util: ${d.cpu_util_pct.toFixed(1)}%`);
}).on('mouseleave', () => tip.style('display','none'));
g.selectAll('.link').on('mousemove', (e, d) => {
tip.style('display','block')
.style('left', (e.pageX+12)+'px').style('top', (e.pageY+12)+'px')
.text(
`channel: ${d.name}\nfill: ${d.fill_pct.toFixed(1)}% peak: ${d.peak_pct.toFixed(1)}%` +
`\ncapacity: ${d.capacity} current: ${d.current}` +
`\npushes: ${d.pushes} drops: ${d.drops} overflows: ${d.overflows}` +
`\nbandwidth: ${d.bw_mbs.toFixed(2)} MB/s item: ${d.item_bytes} B`);
}).on('mouseleave', () => tip.style('display','none'));
}
function renderResources(resources) {
const panel = document.getElementById('resources');
const list = document.getElementById('res-list');
if (!resources || resources.length === 0) { panel.style.display = 'none'; return; }
panel.style.display = 'block';
list.innerHTML = resources.map(r => {
const heldCls = r.held ? 'res-held-y' : 'res-held-n';
const heldTxt = r.held ? 'HELD' : 'free';
return `<div class="res-row">
<span class="res-name">${r.name}</span>
<span class="${heldCls}">${heldTxt}</span>
<span>wait ${r.avg_wait_ms.toFixed(1)}ms</span>
<span>waiters ${r.current_waiters}/${r.peak_waiters}</span>
</div>`;
}).join('');
}
function update(data) {
renderResources(data.resources);
// Update node stats in-place (preserve simulation x/y positions)
const byId = Object.fromEntries(data.nodes.map(n => [n.id, n]));
nodes.forEach(n => {
const fresh = byId[n.id];
if (fresh) {
n.frames = fresh.frames; n.ema_exec_ms = fresh.ema_exec_ms;
n.max_exec_ms = fresh.max_exec_ms; n.blocked_ms = fresh.blocked_ms;
n.fps = fresh.fps; n.total_cpu_ms = fresh.total_cpu_ms;
n.cpu_util_pct = fresh.cpu_util_pct;
}
});
// Update edge stats in-place (source/target are already D3 node refs — don't overwrite)
data.edges.forEach((e, i) => {
if (!links[i]) return;
links[i].fill_pct = e.fill_pct; links[i].peak_pct = e.peak_pct;
links[i].pushes = e.pushes; links[i].drops = e.drops;
links[i].overflows = e.overflows; links[i].current = e.current;
links[i].bw_mbs = e.bw_mbs;
});
// Re-color
nodeSel.select('circle').attr('fill', d => nodeColor(d.ema_exec_ms));
nodeSel.select('.stats').text(d => `${d.ema_exec_ms.toFixed(1)}ms ${d.fps.toFixed(1)}fps`);
linkSel.attr('stroke', d => edgeColor(d.fill_pct))
.attr('marker-end', d => edgeArrow(d.fill_pct));
edgeLabelSel.text(d => `${d.fill_pct.toFixed(0)}%`);
edgeLabelBwSel.text(d => `${d.bw_mbs.toFixed(1)} MB/s`);
}
function ticked() {
// Clamp nodes to viewport
nodes.forEach(d => {
d.x = Math.max(nodeRadius, Math.min(width() - nodeRadius, d.x));
d.y = Math.max(nodeRadius, Math.min(height() - nodeRadius, d.y));
});
linkSel
.attr('x1', d => d.source.x).attr('y1', d => d.source.y)
.attr('x2', d => { // shorten to node edge
const dx = d.target.x - d.source.x, dy = d.target.y - d.source.y;
const dist = Math.sqrt(dx*dx+dy*dy) || 1;
return d.target.x - (dx/dist)*(nodeRadius+8);
})
.attr('y2', d => {
const dx = d.target.x - d.source.x, dy = d.target.y - d.source.y;
const dist = Math.sqrt(dx*dx+dy*dy) || 1;
return d.target.y - (dy/dist)*(nodeRadius+8);
});
edgeLabelSel
.attr('x', d => (d.source.x + d.target.x)/2)
.attr('y', d => (d.source.y + d.target.y)/2 - 6);
edgeLabelBwSel
.attr('x', d => (d.source.x + d.target.x)/2)
.attr('y', d => (d.source.y + d.target.y)/2 + 8);
nodeSel.attr('transform', d => `translate(${d.x},${d.y})`);
}
let initialised = false;
async function poll() {
try {
const r = await fetch('/api/snapshot');
if (!r.ok) throw new Error(r.status);
const data = await r.json();
document.getElementById('status').textContent =
`last update: ${new Date().toLocaleTimeString()} ${data.nodes.length} nodes, ${data.edges.length} edges`;
if (!initialised) { init(data); initialised = true; }
else { update(data); }
} catch(e) {
document.getElementById('status').textContent = `error: ${e}`;
}
}
poll();
setInterval(poll, 500);
window.addEventListener('resize', () => sim && sim.force('center', d3.forceCenter(width()/2, height()/2)).alpha(0.1).restart());
</script>
</body>
</html>
)html";
// ── WebDebugServer ────────────────────────────────────────────────────────────
class WebDebugServer {
public:
using SnapshotFn = std::function<std::string()>;
explicit WebDebugServer(uint16_t port, SnapshotFn fn,
const char* custom_html = nullptr)
: port_(port), snapshot_fn_(std::move(fn))
, html_(custom_html ? custom_html : HTML) {}
void start() {
svr_.Get("/", [this](const httplib::Request&, httplib::Response& res) {
res.set_content(html_, "text/html");
});
svr_.Get("/api/snapshot", [this](const httplib::Request&, httplib::Response& res) {
res.set_content(snapshot_fn_(), "application/json");
});
thread_ = std::thread([this] {
svr_.listen("0.0.0.0", static_cast<int>(port_));
});
}
void stop() {
svr_.stop();
if (thread_.joinable()) thread_.join();
}
~WebDebugServer() { stop(); }
WebDebugServer(const WebDebugServer&) = delete;
WebDebugServer& operator=(const WebDebugServer&) = delete;
private:
uint16_t port_;
SnapshotFn snapshot_fn_;
const char* html_;
httplib::Server svr_;
std::thread thread_;
};
} // namespace kpn::web_debug