From 19d0e0c4d81189af44ad76778b04316fe08899f6 Mon Sep 17 00:00:00 2001 From: Human and Agent dVPN <271368948+Sentinel-Autonomybuilder@users.noreply.github.com> Date: Thu, 18 Jun 2026 01:32:19 -0700 Subject: [PATCH 1/2] fix(specs): report Docker resource availability accurately, not as a reservation The specs:v1 on-chain memo exposes cr/rr alongside total host cores/RAM. Both were labeled "reserved for the dvpn-node container", but the node container is launched with no CPU or memory cap (no HostConfig Memory/NanoCpus), so nothing is actually reserved. The values were sourced from `docker info` NCPU/MemTotal (the Docker engine totals) which is the real ceiling a container can draw from, not a reservation. On Windows this produced a concrete inaccuracy: the engine runs inside the WSL2 VM, whose default RAM allocation is min(50% host, 8 GB). A 16 GB host therefore published rr=16 GB while the container could only ever see ~8 GB. This change keeps the (correct) engine-derived values and fixes the semantics: cr/rr now mean "available to the container (Docker/WSL2 VM)", with r/c as the host totals, so the two fields carry distinct, meaningful info instead of looking like redundant duplicates. No runtime/behavior change; the node still runs uncapped. - node-specs.ts: rewrite memo schema doc; relabel captureLocalSpecs comment; captureRemoteSpecs now probes `docker info --format {{.NCPU}}|{{.MemTotal}}` over SSH so remote reports the engine view too (bail-safe, falls back to /proc host totals when Docker is unreachable). - shared/types.ts: NodeSpecsSnapshot doc updated. - Renderer labels updated to "available": OnChainSpecs (explainer, meter, field descs), System (Docker resource cells), ManageDocker (card title + stats), NodeDetails (RAM available), DeployLocal (explainer bullet). Does not set HostConfig limits; user-settable reservations is a separate, deferred change. --- src/main/services/node-specs.ts | 68 ++++++++++++++++++----- src/renderer/src/screens/DeployLocal.tsx | 4 +- src/renderer/src/screens/ManageDocker.tsx | 22 ++++---- src/renderer/src/screens/NodeDetails.tsx | 2 +- src/renderer/src/screens/OnChainSpecs.tsx | 40 ++++++------- src/renderer/src/screens/System.tsx | 29 +++++----- src/shared/types.ts | 13 +++-- 7 files changed, 116 insertions(+), 62 deletions(-) diff --git a/src/main/services/node-specs.ts b/src/main/services/node-specs.ts index c5bcc90..3a4104c 100644 --- a/src/main/services/node-specs.ts +++ b/src/main/services/node-specs.ts @@ -31,10 +31,20 @@ import { getNode, getSSH, updateNode } from './node-manager'; * Memo schema v1 (compact JSON, ≤ 240 bytes after `specs:v1:`): * { cpu, c, cr, r, rr } * cpu – CPU model string, truncated to 64 chars - * c – total logical cores - * cr – cores reserved for the dvpn-node container - * r – total RAM (MiB) - * rr – RAM reserved for the dvpn-node container (MiB) + * c – total logical cores on the host + * cr – cores AVAILABLE to the dvpn-node container (Docker/WSL2 VM) + * r – total host RAM (MiB) + * rr – RAM AVAILABLE to the dvpn-node container (Docker/WSL2 VM, MiB) + * + * NOTE on `cr`/`rr` semantics: the node container is launched with no CPU or + * memory cap (see docker.ts HostConfig — no Memory/NanoCpus), so nothing is + * "reserved". On Windows the container runs inside the WSL2 VM, which the + * Docker engine sizes BELOW the host (default RAM = min(50% host, 8 GB)). + * `docker info` NCPU/MemTotal report that VM allocation — the real ceiling a + * container can draw from — which is why `cr`/`rr` mean "available to the + * container", not "reserved for it". On a single-purpose Linux VPS the VM == + * host, so cr==c and rr==r there. If we ever set real HostConfig limits, these + * become true reservations and the schema should bump to v2. */ const MEMO_PREFIX = 'specs:v1:'; @@ -77,9 +87,14 @@ export async function captureLocalSpecs(): Promise { return { cpu: truncateCpu(report.cpuModel), c: report.cpuCores, - // dockerOverview().ncpu is what the Docker daemon advertises as available - // to containers — the closest "reservation" signal we have without - // inspecting the running container directly. + // `cr`/`rr` = resources AVAILABLE to the container, not reserved for it. + // The node container runs with no CPU/memory cap, so its real ceiling is + // whatever the Docker engine exposes. On Windows that's the WSL2 VM + // allocation (docker info NCPU/MemTotal), which sits below the host total + // (default RAM = min(50% host, 8 GB)) — so `rr` < `r` there and the two + // fields carry distinct, meaningful info. Fall back to host totals only if + // the docker info probe failed (engine down), since on a single-purpose + // box the VM == host anyway. cr: dockerNcpu ?? report.cpuCores, r: report.memoryMb, rr: dockerMemMb ?? report.memoryMb, @@ -112,16 +127,43 @@ export async function captureRemoteSpecs(creds: SSHCredentials): Promise 0) dockerNcpu = ncpu; + if (Number.isFinite(memBytes) && memBytes > 0) { + dockerMemMb = Math.round(memBytes / (1024 * 1024)); + } + } catch (err) { + log.debug('remote docker info probe failed during specs capture', { + err: String(err), + }); + } + return { cpu: truncateCpu(cpuModel), c, - // No remote Docker reservation probe yet — assume the dvpn-node - // container can use the whole host on a single-purpose VPS, which - // is how operators actually deploy. Refine in v2 if reservations - // become a thing on remote hosts. - cr: c, + // `cr`/`rr` = resources AVAILABLE to the container (Docker engine view), + // not reserved. Falls back to host totals when the engine probe fails. + cr: dockerNcpu ?? c, r, - rr: r, + rr: dockerMemMb ?? r, }; }); } diff --git a/src/renderer/src/screens/DeployLocal.tsx b/src/renderer/src/screens/DeployLocal.tsx index c3502bc..c34bd52 100644 --- a/src/renderer/src/screens/DeployLocal.tsx +++ b/src/renderer/src/screens/DeployLocal.tsx @@ -670,8 +670,8 @@ function OnChainSpecsCard() { operator address with a specs:v1 memo. - The memo carries CPU model, total cores, RAM, and the slice reserved - for the dvpn-node container. + The memo carries CPU model, total host cores and RAM, and the cores + and RAM available to the dvpn-node container (Docker/WSL2 VM). Operator-reported — not consensus-validated. Surfaced in diff --git a/src/renderer/src/screens/ManageDocker.tsx b/src/renderer/src/screens/ManageDocker.tsx index 7feab2f..0ae1769 100644 --- a/src/renderer/src/screens/ManageDocker.tsx +++ b/src/renderer/src/screens/ManageDocker.tsx @@ -688,11 +688,13 @@ function SystemCard({ // skeleton with placeholder rows so the right column is the same height // it'll be after data arrives. if (loaded && !overview?.reachable) return null; - const ramReserved = + // Docker engine pool = resources available to containers (on Windows this is + // the WSL2 VM allocation, below the host total). Not a per-node reservation. + const ramAvailable = overview?.totalMemoryMb ? `${fmtAmount(overview.totalMemoryMb / 1024, 1)} GB` : '—'; - const coresReserved = overview?.ncpu ? String(overview.ncpu) : '—'; + const coresAvailable = overview?.ncpu ? String(overview.ncpu) : '—'; const isLinux = window.api.platform === 'linux'; const onOpenSettings = async () => { const r = await window.api.docker.openSettings(); @@ -715,24 +717,24 @@ function SystemCard({
- Resource reservations + Resources available to containers
- Bigger reservations let one node serve more concurrent users. + A bigger pool lets one node serve more concurrent users. {isLinux ? ' On Linux, edit /etc/docker/daemon.json and restart the daemon.' : ' Edit these in Docker Desktop → Settings → Resources.'} diff --git a/src/renderer/src/screens/NodeDetails.tsx b/src/renderer/src/screens/NodeDetails.tsx index d2139c7..826f4f6 100644 --- a/src/renderer/src/screens/NodeDetails.tsx +++ b/src/renderer/src/screens/NodeDetails.tsx @@ -1396,7 +1396,7 @@ function SpecsReportingPanel({ value={{`${Math.round(specs.r / 1024)} GiB`}} /> {`${Math.round(specs.rr / 1024)} GiB`}} /> diff --git a/src/renderer/src/screens/OnChainSpecs.tsx b/src/renderer/src/screens/OnChainSpecs.tsx index 0341c39..9983680 100644 --- a/src/renderer/src/screens/OnChainSpecs.tsx +++ b/src/renderer/src/screens/OnChainSpecs.tsx @@ -130,7 +130,7 @@ export function OnChainSpecs() { className="btn btn-secondary" onClick={() => void loadSystem()} disabled={refreshing} - title="Re-read CPU, RAM and Docker reservation" + title="Re-read CPU, RAM and Docker resource availability" > {refreshing ? 'Refreshing…' : 'Refresh'} @@ -318,9 +318,9 @@ function ExplainerCard() { - + 0 ? docker!.ncpu! : totalCores; - const reservedRamMb = + const availRamMb = dockerOk && Number.isFinite(docker!.totalMemoryMb) && docker!.totalMemoryMb! > 0 ? docker!.totalMemoryMb! : totalRamMb; - const coreRatio = totalCores > 0 ? reservedCores / totalCores : 0; - const ramRatio = totalRamMb > 0 ? reservedRamMb / totalRamMb : 0; + const coreRatio = totalCores > 0 ? availCores / totalCores : 0; + const ramRatio = totalRamMb > 0 ? availRamMb / totalRamMb : 0; const totalRamGb = totalRamMb / 1024; - const reservedRamGb = reservedRamMb / 1024; + const availRamGb = availRamMb / 1024; const fmtRam = (mb: number, gb: number) => mb > 0 ? `${gb.toFixed(1)} GB` : '—'; return ( @@ -576,19 +578,19 @@ function SpecsSnapshotCard({
- {/* Reserved / total meters */} + {/* Available-to-container / host-total meters */}
@@ -601,13 +603,13 @@ function SpecsSnapshotCard({ function SpecsMeter({ icon, label, - reserved, + available, total, ratio, }: { icon: string; label: string; - reserved: string; + available: string; total: string; ratio: number; }) { @@ -629,14 +631,14 @@ function SpecsMeter({ className="text-[8.5px] uppercase tracking-[0.12em]" style={{ color: 'var(--text-dim)' }} > - Reserved / Total + Available / Total - {reserved} + {available} / {total} @@ -726,17 +728,17 @@ function SpecsMemoCard({ style={{ color: 'var(--text-muted)' }} > - + - +
diff --git a/src/renderer/src/screens/System.tsx b/src/renderer/src/screens/System.tsx index 6fa9a7f..23e668d 100644 --- a/src/renderer/src/screens/System.tsx +++ b/src/renderer/src/screens/System.tsx @@ -481,9 +481,12 @@ function DockerLimitsRow({ report: LocalSystemReport; }) { const reachable = !!docker && docker.reachable; - const reservedRamMb = + // Docker engine pool = resources available to containers (on Windows this is + // the WSL2 VM allocation, which sits below the host total). Not "reserved" — + // the node container itself runs uncapped. + const availRamMb = reachable && Number.isFinite(docker!.totalMemoryMb) ? docker!.totalMemoryMb! : 0; - const reservedCores = + const availCores = reachable && Number.isFinite(docker!.ncpu) ? docker!.ncpu! : 0; const totalRamMb = Number.isFinite(report.memoryMb) ? report.memoryMb : 0; const totalCores = Number.isFinite(report.cpuCores) ? report.cpuCores : 0; @@ -505,21 +508,21 @@ function DockerLimitsRow({
@@ -530,7 +533,7 @@ function DockerLimitsRow({ function DockerLimitCell({ label, - reserved, + value, total, unit, decimals, @@ -539,7 +542,7 @@ function DockerLimitCell({ divider, }: { label: string; - reserved: number; + value: number; total: number; unit: string; decimals: number; @@ -550,8 +553,8 @@ function DockerLimitCell({ const fmt = (n: number) => Number.isFinite(n) && n > 0 ? n.toFixed(decimals) : '—'; const pct = - total > 0 && reserved > 0 - ? Math.max(0, Math.min(100, (reserved / total) * 100)) + total > 0 && value > 0 + ? Math.max(0, Math.min(100, (value / total) * 100)) : 0; return (
- {fmt(reserved)} + {fmt(value)} Date: Thu, 18 Jun 2026 01:32:35 -0700 Subject: [PATCH 2/2] test(metrics): skip metrics store suite with a reason on ABI mismatch better-sqlite3 is a single-ABI native addon. The shipping app builds it for Electron's ABI via `npm run rebuild:electron`, which the vitest runner (plain Node, a different NODE_MODULE_VERSION) physically cannot dlopen. When that's the case the metrics store degrades to a no-op, so the assertions silently "passed" against a disabled store before and fail (0 rows) when the binary happens to match neither runtime. Probe the binding once (open an in-memory DB to force the dlopen) and describe.skip the suite WITH A logged reason when it can't load, instead of running against a dead store. The full logic still runs whenever the runner ABI matches the built binary (`npm rebuild better-sqlite3`, or under Electron). --- tests/unit/metrics.test.ts | 43 +++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/tests/unit/metrics.test.ts b/tests/unit/metrics.test.ts index 9c9b6a0..bd863e9 100644 --- a/tests/unit/metrics.test.ts +++ b/tests/unit/metrics.test.ts @@ -1,8 +1,49 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createRequire } from 'node:module'; import path from 'node:path'; import os from 'node:os'; import fs from 'node:fs/promises'; +/** + * `better-sqlite3` is a native addon whose compiled `.node` is built for ONE + * NODE_MODULE_VERSION at a time. The shipping app runs on Electron (ABI 140 + * for Electron 39), so `npm run rebuild:electron` produces an Electron-ABI + * binary — which the vitest runner (plain Node, ABI 137) physically cannot + * `dlopen`. When that's the case the metrics store correctly degrades to a + * no-op (see metrics.ts getDB() catch), so these assertions can't run. + * + * Rather than silently pass against a disabled store (which would hide real + * regressions) we probe the binding once and skip the suite WITH A REASON + * when it can't load under the current runtime. The full logic is still + * exercised whenever the runner ABI matches the built binary (e.g. after + * `npm rebuild better-sqlite3` for Node, or when run under Electron). + */ +function sqliteLoadsUnderRunner(): { ok: boolean; reason?: string } { + try { + const require = createRequire(import.meta.url); + const Database = require('better-sqlite3'); + // require() returns the JS wrapper without dlopen'ing the addon — force + // the native bindings to load by actually opening an in-memory DB. + new Database(':memory:').close(); + return { ok: true }; + } catch (err) { + return { ok: false, reason: (err as Error).message.split('\n')[0] }; + } +} + +const sqlite = sqliteLoadsUnderRunner(); +if (!sqlite.ok) { + // Surfaced in the vitest run so the skip is never silent. + console.warn( + `[metrics.test] SKIPPING — better-sqlite3 native binding unavailable under this runtime ` + + `(node ABI ${process.versions.modules}). The shipping app builds it for Electron's ABI ` + + `via \`npm run rebuild:electron\`; to run these tests under vitest run ` + + `\`npm rebuild better-sqlite3\` first. Reason: ${sqlite.reason}`, + ); +} + +const describeMaybe = sqlite.ok ? describe : describe.skip; + // `electron` is a native module we don't have in test — stub it. vi.mock('electron', () => ({ app: { @@ -30,7 +71,7 @@ afterEach(async () => { delete process.env['SENTINEL_TEST_USERDATA']; }); -describe('metrics store', () => { +describeMaybe('metrics store', () => { it('records and queries samples for a node within the window', async () => { const { recordSample, history } = await import('../../src/main/services/metrics'); const now = Date.now();