Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .mise/config.js.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@
# same npm-package-served-as-a-module pattern as onnxruntime-web (resolved in libs/edge-toolkit/src/config.rs).
"npm:@mediapipe/tasks-vision" = "latest"
"npm:onnxruntime-web" = "latest"
# stats-gl is the GPU utilisation meter overlay on the ws-server index page.
# index.html loads it at runtime via /modules/stats-gl (same npm-package-served-as-a-module pattern as
# onnxruntime-web, resolved in libs/edge-toolkit/src/config.rs).
"npm:stats-gl" = "latest"
# oxlint-tsgolint is oxlint's type-aware engine.
# It's a typescript-go type checker oxlint shells out to under `--type-aware`. npm-only: it ships no GitHub
# release assets, distributing per-platform binaries via `@oxlint-tsgolint/<triple>` optional deps, so every OS
Expand Down
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,12 @@ All tasks run through `mise run <task>`. The aggregates below act on Rust + the

## Formatters & checks by file type

**Verify the change actually works before running the lint/format battery.** Functional verification comes first:
run the affected tests, exercise the endpoint, or load the page in the browser and confirm the behaviour is right.
Only then move on to the formatter/check passes below. Linting a broken change wastes the whole battery -- the
functional fix that follows (a renamed symbol, a rewritten block, a CSS edit discovered only in the browser) dirties
the files again and every check has to be re-run. Green checks on code that doesn't work are worthless.

**Agents must not run `mise run check` (or `check-all`).** They are aggregate gates intended for CI and humans --
running the whole battery for every edit is slow, expensive, and almost always wasteful when only a couple of file
types changed. Pick the targeted tasks from the tables below that match the extensions of the files you actually
Expand Down
1 change: 1 addition & 0 deletions config/ast-grep/rules/doc-summary-ends-with-period.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ files:
- libs/edge-toolkit/tests/no_mise.rs
- libs/test-helpers/src/lib.rs
- libs/ws-runner-common/tests/config.rs
- services/modules/tests/api_modules.rs
- services/websockify/src/lib.rs
- services/websockify/tests/relay.rs
- services/ws-modules/except1/src/lib.rs
Expand Down
18 changes: 18 additions & 0 deletions libs/edge-toolkit/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,24 @@ pub fn default_modules_folders() -> Vec<PathBuf> {
);
}
}
// stats-gl (the GPU utilisation meter overlay on the ws-server index page). Same
// npm-package-served-as-a-module pattern as onnxruntime-web above.
Comment on lines +132 to +133

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Terminate the new Rust comment summaries with punctuation.

Both files are covered by doc-summary-ends-with-period.yaml, but their added first summary lines end without ., !, or ?.

  • libs/edge-toolkit/src/config.rs#L132-L133: end the first stats-gl summary sentence with terminal punctuation.
  • services/modules/tests/api_modules.rs#L44-L46: end the first JS-package summary sentence with terminal punctuation.

As per coding guidelines, **/*.rs: "Whenever modifying a Rust file, add it to the sorted allowlist in doc-summary-ends-with-period.yaml and fix first-line doc summaries so they end in ., !, or ?."

📍 Affects 2 files
  • libs/edge-toolkit/src/config.rs#L132-L133 (this comment)
  • services/modules/tests/api_modules.rs#L44-L46
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libs/edge-toolkit/src/config.rs` around lines 132 - 133, The first summary
lines in both Rust files lack terminal punctuation. Update the stats-gl summary
in libs/edge-toolkit/src/config.rs lines 132-133 and the JS-package summary in
services/modules/tests/api_modules.rs lines 44-46 to end with ., !, or ?; also
add libs/edge-toolkit/src/config.rs to the sorted allowlist in
doc-summary-ends-with-period.yaml.

Source: Coding guidelines

match mise_npm_modules_path("stats-gl") {
Some(path) => {
log::info!("Resolved npm:stats-gl modules path: {}", path.display());
paths.push(path);
}
None => {
log::warn!(
"{}",
concat!(
"npm:stats-gl install path not found via `mise where` -- ",
"requests to /modules/stats-gl/* will 404. ",
"Run `mise install npm:stats-gl` and verify the package layout.",
)
);
}
}
// Pyodide is installed from its GitHub release tarball (see
// `.mise/config.python.toml`),
// not via `npm:pyodide`. mise's http backend extracts the archive flat,
Expand Down
7 changes: 4 additions & 3 deletions services/modules/tests/api_modules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,11 @@ async fn list_modules_api() {
}
}

// onnxruntime-web is staged by the `js` env (npm:onnxruntime-web in
// config.js.toml); when MISE_ENV doesn't load js, the package isn't on
// disk and the modules listing doesn't include it.
// onnxruntime-web and stats-gl are staged by the `js` env (npm:onnxruntime-web
// and npm:stats-gl in config.js.toml); when MISE_ENV doesn't load js, the
// packages aren't on disk and the modules listing doesn't include them.
if mise_env_includes(Language::Js) {
assert!(resp.contains(&"onnxruntime-web".to_string()));
assert!(resp.contains(&"stats-gl".to_string()));
}
}
3 changes: 2 additions & 1 deletion services/ws-server/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ COPY . .
# step picks up a real directory.
RUN pnpm add --prod --dir /workspace/runtime-deps --config.node-linker=hoisted \
--ignore-scripts --config.ignoredBuiltDependencies=protobufjs \
onnxruntime-web pyodide
onnxruntime-web pyodide stats-gl
RUN cargo build -p et-ws-server --release --locked

FROM debian:bookworm-slim AS runtime
Expand All @@ -43,6 +43,7 @@ COPY --from=builder /workspace/services/ws-modules ./services/ws-modules
COPY --from=builder /workspace/data/model-modules ./data/model-modules
COPY --from=builder /workspace/runtime-deps/node_modules/onnxruntime-web ./node_modules/onnxruntime-web
COPY --from=builder /workspace/runtime-deps/node_modules/pyodide ./node_modules/pyodide
COPY --from=builder /workspace/runtime-deps/node_modules/stats-gl ./node_modules/stats-gl

RUN mkdir -p /app/storage \
&& chown -R app:app /app
Expand Down
1 change: 1 addition & 0 deletions services/ws-server/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,6 @@ <h1>WASM web agent</h1>
<pre id="log">Booting...</pre>
</main>
<script type="module" src="/app.js"></script>
<script type="module" src="/meters.js"></script>
</body>
</html>
285 changes: 285 additions & 0 deletions services/ws-server/static/meters.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,285 @@
// GPU utilisation meter overlay, backed by the stats-gl npm package (served at /modules/stats-gl).
//
// stats-gl's GPU panel needs WebGPU "timestamp-query" timestamps written around real GPU passes.
// This page owns no render loop, so a tiny fixed compute probe is dispatched every animation frame
// with stats-gl's timestampWrites: the panel then reports how long that constant workload takes on
// the device, which rises as other GPU work (module matmuls, onnxruntime-web inference) contends
// with it. Only the GPU panel is shown (no FPS/CPU), with a value line sized to span most of the
// panel width and without the flickering "(min-max)" range suffix. Browsers without WebGPU or
// without the timestamp-query feature get no overlay at all. The meter starts in the top-left
// corner; it can be dragged anywhere, resized from its bottom-right corner handle, and closed with
// the corner x button, which also stops the probe and releases the WebGPU device.

import Stats from "/modules/stats-gl/dist/main.js";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parsing error: 'import' and 'export' may appear only with 'sourceType: module'


Found non-compliant syntax. Confirm that there are no syntax errors before committing your code to a version control system.


const PROBE_SHADER = `

Check warning on line 15 in services/ws-server/static/meters.js

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

services/ws-server/static/meters.js#L15

This looks like a JavaScript template string.
@group(0) @binding(0) var<storage, read_write> data: array<f32>;

@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) id: vec3<u32>) {
var acc = data[id.x];
for (var i = 0u; i < 256u; i = i + 1u) {
acc = acc * 1.0000001 + 0.0000001;
}
data[id.x] = acc;
}
`;

const PROBE_INVOCATIONS = 64;

// stats-gl's native panel is 90x48 CSS px; the meter starts at 2x that and the corner handle
// resizes it (aspect locked) between MIN_SCALE and MAX_SCALE.
const PANEL_BASE_WIDTH = 90;
const PANEL_BASE_HEIGHT = 48;
const START_SCALE = 2;
const MIN_SCALE = 1;
const MAX_SCALE = 8;
// Font height per panel device-pixel-ratio unit (stats-gl's own is 9). At bold 16, a worst-case
// "999.99 GPU" value line spans ~85 of the 90-unit panel width, so the text mostly fills the meter.
const FONT_PX = 16;

const requestTimestampDevice = async () => {
if (!navigator.gpu) {
return null;
}
const adapter = await navigator.gpu.requestAdapter();
if (!adapter || !adapter.features.has("timestamp-query")) {
return null;
}
return adapter.requestDevice({ requiredFeatures: ["timestamp-query"] });
};

const createProbe = async (device) => {
const module = device.createShaderModule({ code: PROBE_SHADER });
const pipeline = await device.createComputePipelineAsync({
layout: "auto",
compute: { module, entryPoint: "main" },
});
const buffer = device.createBuffer({

Check warning on line 58 in services/ws-server/static/meters.js

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

services/ws-server/static/meters.js#L58

Avoid trailing commas in object or array literals
size: PROBE_INVOCATIONS * 4,
usage: GPUBufferUsage.STORAGE,
});
const bindGroup = device.createBindGroup({

Check warning on line 62 in services/ws-server/static/meters.js

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

services/ws-server/static/meters.js#L62

Avoid trailing commas in object or array literals
layout: pipeline.getBindGroupLayout(0),
entries: [{ binding: 0, resource: { buffer } }],
});
return { bindGroup, device, pipeline };

Check notice on line 66 in services/ws-server/static/meters.js

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

services/ws-server/static/meters.js#L66

Unnecessary block.
};

const encodeProbePass = (stats, probe) => {
const encoder = probe.device.createCommandEncoder();
const pass = encoder.beginComputePass({ timestampWrites: stats.getTimestampWrites("render") });
pass.setPipeline(probe.pipeline);
pass.setBindGroup(0, probe.bindGroup);
pass.dispatchWorkgroups(1);
pass.end();
stats.end(encoder);
probe.device.queue.submit([encoder.finish()]);
};

// stats-gl hardcodes the panel's 90x48 layout, 15-unit text strip, and 9-unit font in the Panel
// constructor, so re-derive the metrics here for a given scale. Scaling the panel's device-pixel-
// ratio from a stored base (rather than stretching the canvas with CSS) keeps the meter crisp at
// every size, and the taller text strip plus FONT_PX make the value line span most of the panel
// width. The container is sized to match so the corner controls anchor to its edges and the drag
// clamp can read its offset size. update()/updateGraph() read all of these fields per frame, and
// never reset the font, so the overrides stick.
const applyMeterScale = (el, panel, scale) => {
panel.basePR ??= panel.PR;
const pr = panel.basePR * scale;
panel.PR = pr;
panel.WIDTH = PANEL_BASE_WIDTH * pr;
panel.HEIGHT = PANEL_BASE_HEIGHT * pr;
panel.TEXT_X = 3 * pr;
panel.TEXT_Y = 3 * pr;
panel.GRAPH_X = 3 * pr;
panel.GRAPH_Y = 22 * pr;
panel.GRAPH_WIDTH = 84 * pr;
panel.GRAPH_HEIGHT = 23 * pr;
panel.canvas.width = panel.WIDTH;
panel.canvas.height = panel.HEIGHT;
panel.canvas.style.width = `${PANEL_BASE_WIDTH * scale}px`;
panel.canvas.style.height = `${PANEL_BASE_HEIGHT * scale}px`;
el.style.width = `${PANEL_BASE_WIDTH * scale}px`;
el.style.height = `${PANEL_BASE_HEIGHT * scale}px`;
panel.initializeCanvas();
panel.context.font = `bold ${FONT_PX * pr}px Helvetica,Arial,sans-serif`;
};

// panel.update draws a flickering " (min-max)" range as a separate fillText call after the value;
// dropping that call at the context level keeps one steady number without forking the panel
// drawing code. The wrapper survives resizes: reallocating the canvas resets context state but
// keeps the same context object, and with it this own-property override.
const suppressRangeSuffix = (panel) => {
const fillText = panel.context.fillText.bind(panel.context);
panel.context.fillText = (...args) => {
if (!String(args[0]).trimStart().startsWith("(")) {
fillText(...args);
}
};
};

// Move/up handlers for dragging and resizing sit on window rather than using setPointerCapture:
// capture can silently drop in embedded browser panes, stranding the gesture after one step, while
// window listeners keep receiving events wherever the cursor goes.
const makeDraggable = (el) => {
let activePointerId = null;
let offsetX = 0;
let offsetY = 0;
el.style.cursor = "grab";
el.style.touchAction = "none";

el.addEventListener("pointerdown", (event) => {
activePointerId = event.pointerId;
offsetX = event.clientX - el.offsetLeft;
offsetY = event.clientY - el.offsetTop;
el.style.cursor = "grabbing";
event.preventDefault();
});

window.addEventListener("pointermove", (event) => {
if (activePointerId === null || event.pointerId !== activePointerId) {
return;
}
const x = event.clientX - offsetX;
const y = event.clientY - offsetY;
el.style.left = `${Math.min(Math.max(x, 0), window.innerWidth - el.offsetWidth)}px`;
el.style.top = `${Math.min(Math.max(y, 0), window.innerHeight - el.offsetHeight)}px`;
});

const release = (event) => {
if (event.pointerId === activePointerId) {
activePointerId = null;
el.style.cursor = "grab";
}
};
window.addEventListener("pointerup", release);
window.addEventListener("pointercancel", release);
};
Comment on lines +125 to +158

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

git ls-files services/ws-server/static/meters.js && wc -l services/ws-server/static/meters.js && sed -n '1,260p' services/ws-server/static/meters.js

Repository: edge-toolkit/core

Length of output: 9694


🏁 Script executed:

python3 - <<'PY'
from pathlib import Path
p = Path('services/ws-server/static/meters.js')
print(p.exists(), p.stat().st_size if p.exists() else None)
PY

Repository: edge-toolkit/core

Length of output: 165


🏁 Script executed:

rg -n "makeDraggable|device\\.lost|stats-gl|requestAnimationFrame|pointerdown|pointermove|pointerup|pointercancel" services/ws-server/static/meters.js

Repository: edge-toolkit/core

Length of output: 1637


🏁 Script executed:

sed -n '1,340p' services/ws-server/static/meters.js

Repository: edge-toolkit/core

Length of output: 10129


Centralize GPU meter teardown. Add an idempotent stop() that cancels RAF, removes the window listeners, removes the overlay, and disposes Stats on close, setup failure, and device.lost; otherwise the global listeners keep the overlay reachable and the frame loop keeps running after device loss.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/ws-server/static/meters.js` around lines 125 - 158, The GPU meter
lifecycle lacks centralized teardown, leaving animation and global listeners
active after closure or device loss. Add an idempotent stop() in the GPU meter
setup that cancels the RAF, removes window listeners, removes the overlay, and
disposes Stats; invoke it on close, setup failure, and device.lost, while
reusing the existing listener and overlay references.


// Corner controls swallow pointerdown so the drag handler underneath doesn't fire.
const addCloseButton = (el, onClose) => {
const button = document.createElement("button");
button.type = "button";
button.textContent = "x";
button.title = "Close GPU meter";
Object.assign(button.style, {

Check warning on line 166 in services/ws-server/static/meters.js

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

services/ws-server/static/meters.js#L166

Avoid trailing commas in object or array literals
background: "rgba(0,0,0,0.55)",
border: "0",
borderRadius: "3px",
color: "#ddd",
cursor: "pointer",
font: "bold 11px/16px sans-serif",
height: "16px",
padding: "0",
position: "absolute",
right: "2px",
top: "2px",
width: "16px",
});
button.addEventListener("pointerdown", (event) => event.stopPropagation());
button.addEventListener("click", onClose);
el.appendChild(button);
};

const addResizeHandle = (el, onScale) => {
const handle = document.createElement("div");
handle.title = "Resize GPU meter";
Object.assign(handle.style, {

Check warning on line 188 in services/ws-server/static/meters.js

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

services/ws-server/static/meters.js#L188

Avoid trailing commas in object or array literals
background: "linear-gradient(135deg,transparent 50%,rgba(255,255,255,0.4) 50%)",
bottom: "0",
cursor: "nwse-resize",
height: "14px",
position: "absolute",
right: "0",
width: "14px",
});
let activePointerId = null;
let startX = 0;
let startWidth = 0;

handle.addEventListener("pointerdown", (event) => {
event.stopPropagation();
event.preventDefault();
activePointerId = event.pointerId;
startX = event.clientX;
startWidth = el.offsetWidth;
});

window.addEventListener("pointermove", (event) => {
if (activePointerId === null || event.pointerId !== activePointerId) {
return;
}
const scale = (startWidth + event.clientX - startX) / PANEL_BASE_WIDTH;
onScale(Math.min(Math.max(scale, MIN_SCALE), MAX_SCALE));
});

const release = (event) => {
if (event.pointerId === activePointerId) {
activePointerId = null;
}
};
window.addEventListener("pointerup", release);
window.addEventListener("pointercancel", release);
el.appendChild(handle);
};

const startMeters = async () => {
let device = null;
try {
device = await requestTimestampDevice();
} catch (error) {
console.warn("meters: WebGPU device request failed:", error);
}
if (!device) {
console.warn("meters: WebGPU timestamp queries unavailable, GPU meter not shown");
return;
}

const stats = new Stats({ trackFPS: false, trackGPU: true });
let probe = null;
try {
await stats.init(device);
probe = await createProbe(device);
} catch (error) {
console.warn("meters: WebGPU probe setup failed, GPU meter not shown:", error);
return;
}

const overlay = stats.dom;
applyMeterScale(overlay, stats.gpuPanel, START_SCALE);
suppressRangeSuffix(stats.gpuPanel);
document.body.appendChild(overlay);
makeDraggable(overlay);
addResizeHandle(overlay, (scale) => applyMeterScale(overlay, stats.gpuPanel, scale));

let frameHandle = 0;
addCloseButton(overlay, () => {
cancelAnimationFrame(frameHandle);
probe = null;
overlay.remove();
device.destroy();
});

void device.lost.then((info) => {
if (info.reason === "destroyed") {
return;
}
probe = null;
console.warn(`meters: WebGPU device lost (${info.reason}), GPU meter frozen`);
});

const frame = () => {
stats.begin();
if (probe) {
encodeProbePass(stats, probe);
} else {
stats.end();
}
stats.update();
frameHandle = requestAnimationFrame(frame);
};
frameHandle = requestAnimationFrame(frame);
};

await startMeters();
4 changes: 3 additions & 1 deletion services/ws-server/static/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@
"app.js",
"favicon.png",
"index.html",
"meters.js",
"style.css"
],
"dependencies": {
"et-ws-wasm-agent": "link:../../ws-wasm-agent/pkg",
"onnxruntime-web": "*"
"onnxruntime-web": "*",
"stats-gl": "*"
}
}
Loading
Loading