fix: node-MainThread (#1300)

https://github.com/Termix-SSH/Support/issues/1300
This commit is contained in:
LukeGus committed 2026-09-20 19:12:34 -05:00
1 parent a9a3bbc5f4
commit beb5145475
3 files changed
+144 -57

No files matched your search

+16 -4
View File
@@ -25,13 +25,19 @@ import type {
LogEntry, LogEntry,
ConnectionStage, ConnectionStage,
} from "../../../types/connection-log.js"; } from "../../../types/connection-log.js";
import { collectCpuMetrics } from "./widgets/cpu-collector.js"; import {
collectCpuMetrics,
clearCpuSampleCache,
} from "./widgets/cpu-collector.js";
import { collectMemoryMetrics } from "./widgets/memory-collector.js"; import { collectMemoryMetrics } from "./widgets/memory-collector.js";
import { import {
collectDiskMetrics, collectDiskMetrics,
type DiskFilesystem, type DiskFilesystem,
} from "./widgets/disk-collector.js"; } from "./widgets/disk-collector.js";
import { collectNetworkMetrics } from "./widgets/network-collector.js"; import {
collectNetworkMetrics,
clearNetworkSampleCache,
} from "./widgets/network-collector.js";
import { collectUptimeMetrics } from "./widgets/uptime-collector.js"; import { collectUptimeMetrics } from "./widgets/uptime-collector.js";
import { collectProcessesMetrics } from "./widgets/processes-collector.js"; import { collectProcessesMetrics } from "./widgets/processes-collector.js";
import { collectSystemMetrics } from "./widgets/system-collector.js"; import { collectSystemMetrics } from "./widgets/system-collector.js";
@@ -515,6 +521,8 @@ class PollingManager {
this.syncPollConcurrency(); this.syncPollConcurrency();
this.statusStore.delete(host.id); this.statusStore.delete(host.id);
this.metricsStore.delete(host.id); this.metricsStore.delete(host.id);
clearCpuSampleCache(host.id);
clearNetworkSampleCache(host.id);
return; return;
} }
@@ -848,6 +856,8 @@ class PollingManager {
if (clearData) { if (clearData) {
this.statusStore.delete(hostId); this.statusStore.delete(hostId);
this.metricsStore.delete(hostId); this.metricsStore.delete(hostId);
clearCpuSampleCache(hostId);
clearNetworkSampleCache(hostId);
} }
} }
hostPollCache.invalidate(hostId); hostPollCache.invalidate(hostId);
@@ -917,6 +927,8 @@ class PollingManager {
if (!currentHostIds.has(hostId)) { if (!currentHostIds.has(hostId)) {
this.statusStore.delete(hostId); this.statusStore.delete(hostId);
this.metricsStore.delete(hostId); this.metricsStore.delete(hostId);
clearCpuSampleCache(hostId);
clearNetworkSampleCache(hostId);
} }
} }
@@ -1771,7 +1783,7 @@ async function collectMetrics(
const collectFn = async (client: Client) => { const collectFn = async (client: Client) => {
onAuthenticated?.(); onAuthenticated?.();
const platform = await detectPlatform(client); const platform = await detectPlatform(client);
const cpu = await collectCpuMetrics(client, platform); const cpu = await collectCpuMetrics(client, platform, host.id);
const memory = await collectMemoryMetrics(client, platform); const memory = await collectMemoryMetrics(client, platform);
const disk = await collectDiskMetrics( const disk = await collectDiskMetrics(
client, client,
@@ -1779,7 +1791,7 @@ async function collectMetrics(
monitoredMounts, monitoredMounts,
platform, platform,
); );
const network = await collectNetworkMetrics(client, platform); const network = await collectNetworkMetrics(client, platform, host.id);
const uptime = await collectUptimeMetrics(client, platform); const uptime = await collectUptimeMetrics(client, platform);
const processes = await collectProcessesMetrics(client); const processes = await collectProcessesMetrics(client);
const system = await collectSystemMetrics(client, platform); const system = await collectSystemMetrics(client, platform);
@@ -6,6 +6,24 @@ import {
type HostPlatform, type HostPlatform,
} from "./common-utils.js"; } from "./common-utils.js";
/**
* CPU percent is derived from the delta against the previous poll's
* /proc/stat sample rather than a same-poll double-read, so a poll no longer
* has to block for a fixed settle window. Keyed by host.
*/
const previousStat = new Map<
number,
{ total: number; idle: number; timestamp: number }
>();
export function clearCpuSampleCache(hostId?: number): void {
if (hostId === undefined) {
previousStat.clear();
return;
}
previousStat.delete(hostId);
}
export function parseCpuLine( export function parseCpuLine(
cpuLine: string, cpuLine: string,
): { total: number; idle: number } | undefined { ): { total: number; idle: number } | undefined {
@@ -130,6 +148,7 @@ async function collectWindowsCpuMetrics(client: Client): Promise<{
export async function collectCpuMetrics( export async function collectCpuMetrics(
client: Client, client: Client,
platform?: HostPlatform, platform?: HostPlatform,
hostId?: number,
): Promise<{ ): Promise<{
percent: number | null; percent: number | null;
cores: number | null; cores: number | null;
@@ -147,7 +166,8 @@ export async function collectCpuMetrics(
let loadTriplet: [number, number, number] | null = null; let loadTriplet: [number, number, number] | null = null;
try { try {
const [stat1, loadAvgOut, coresOut] = await Promise.race([ const readAt = Date.now();
const [statOut, loadAvgOut, coresOut] = await Promise.race([
Promise.all([ Promise.all([
execCommand(client, "cat /proc/stat"), execCommand(client, "cat /proc/stat"),
execCommand(client, "cat /proc/loadavg"), execCommand(client, "cat /proc/loadavg"),
@@ -164,24 +184,22 @@ export async function collectCpuMetrics(
), ),
]); ]);
await new Promise((r) => setTimeout(r, 500)); const cpuLine = (
const stat2 = await execCommand(client, "cat /proc/stat"); statOut.stdout.split("\n").find((l) => l.startsWith("cpu ")) || ""
const cpuLine1 = (
stat1.stdout.split("\n").find((l) => l.startsWith("cpu ")) || ""
).trim(); ).trim();
const cpuLine2 = ( const current = parseCpuLine(cpuLine);
stat2.stdout.split("\n").find((l) => l.startsWith("cpu ")) || "" const previous =
).trim(); hostId !== undefined ? previousStat.get(hostId) : undefined;
const a = parseCpuLine(cpuLine1); if (current && previous) {
const b = parseCpuLine(cpuLine2); const totalDiff = current.total - previous.total;
if (a && b) { const idleDiff = current.idle - previous.idle;
const totalDiff = b.total - a.total;
const idleDiff = b.idle - a.idle;
const used = totalDiff - idleDiff; const used = totalDiff - idleDiff;
if (totalDiff > 0) if (totalDiff > 0)
cpuPercent = Math.max(0, Math.min(100, (used / totalDiff) * 100)); cpuPercent = Math.max(0, Math.min(100, (used / totalDiff) * 100));
} }
if (current && hostId !== undefined) {
previousStat.set(hostId, { ...current, timestamp: readAt });
}
const laParts = loadAvgOut.stdout.trim().split(/\s+/); const laParts = loadAvgOut.stdout.trim().split(/\s+/);
if (laParts.length >= 3) { if (laParts.length >= 3) {
@@ -44,6 +44,31 @@ export function counterRate(
return Math.round((second - first) / elapsedSeconds); return Math.round((second - first) / elapsedSeconds);
} }
/**
* Rate is derived from the previous poll's counters rather than a same-poll
* double-sample, so a poll no longer has to block for a fixed settle window.
* Keyed by host, since counters from one host are meaningless against another.
*/
const previousSamples = new Map<
number,
{ counters: Map<string, NetworkCounters>; timestamp: number }
>();
const previousWindowsSamples = new Map<
number,
{ rows: WindowsAdapterRow[]; timestamp: number }
>();
export function clearNetworkSampleCache(hostId?: number): void {
if (hostId === undefined) {
previousSamples.clear();
previousWindowsSamples.clear();
return;
}
previousSamples.delete(hostId);
previousWindowsSamples.delete(hostId);
}
export function parseDarwinIfconfig( export function parseDarwinIfconfig(
output: string, output: string,
): Map<string, { ip: string; state: string }> { ): Map<string, { ip: string; state: string }> {
@@ -85,7 +110,10 @@ export function parseDarwinNetstat(
return counters; return counters;
} }
async function collectDarwinNetworkMetrics(client: Client): Promise<{ async function collectDarwinNetworkMetrics(
client: Client,
hostId?: number,
): Promise<{
interfaces: Array<{ interfaces: Array<{
name: string; name: string;
ip: string; ip: string;
@@ -111,31 +139,35 @@ async function collectDarwinNetworkMetrics(client: Client): Promise<{
const ifMap = parseDarwinIfconfig(ifconfigOut.stdout); const ifMap = parseDarwinIfconfig(ifconfigOut.stdout);
try { try {
const firstReadAt = Date.now(); const readAt = Date.now();
const netstat1 = await execCommand(client, "netstat -ib 2>/dev/null"); const netstatOut = await execCommand(client, "netstat -ib 2>/dev/null");
await new Promise((resolve) => setTimeout(resolve, 500)); const current = parseDarwinNetstat(netstatOut.stdout);
const netstat2 = await execCommand(client, "netstat -ib 2>/dev/null"); const previous =
const elapsedSeconds = (Date.now() - firstReadAt) / 1000; hostId !== undefined ? previousSamples.get(hostId) : undefined;
const before = parseDarwinNetstat(netstat1.stdout); const elapsedSeconds = previous
const after = parseDarwinNetstat(netstat2.stdout); ? (readAt - previous.timestamp) / 1000
: 0;
if (ifMap.size === 0) { if (ifMap.size === 0) {
for (const name of before.keys()) { for (const name of current.keys()) {
ifMap.set(name, { ip: "", state: "UNKNOWN" }); ifMap.set(name, { ip: "", state: "UNKNOWN" });
} }
} }
for (const [name, data] of ifMap.entries()) { for (const [name, data] of ifMap.entries()) {
const b = before.get(name); const c = current.get(name);
const a = after.get(name); const p = previous?.counters.get(name);
interfaces.push({ interfaces.push({
name, name,
ip: data.ip, ip: data.ip,
state: data.state, state: data.state,
rxBytes: b?.rx ?? null, rxBytes: c?.rx ?? null,
txBytes: b?.tx ?? null, txBytes: c?.tx ?? null,
rxRateBps: counterRate(b?.rx, a?.rx, elapsedSeconds), rxRateBps: p ? counterRate(p.rx, c?.rx, elapsedSeconds) : null,
txRateBps: counterRate(b?.tx, a?.tx, elapsedSeconds), txRateBps: p ? counterRate(p.tx, c?.tx, elapsedSeconds) : null,
}); });
} }
if (hostId !== undefined) {
previousSamples.set(hostId, { counters: current, timestamp: readAt });
}
} catch { } catch {
for (const [name, data] of ifMap.entries()) { for (const [name, data] of ifMap.entries()) {
interfaces.push({ interfaces.push({
@@ -191,7 +223,10 @@ export function parseWindowsAdapterJson(output: string): WindowsAdapterRow[] {
} }
} }
async function collectWindowsNetworkMetrics(client: Client): Promise<{ async function collectWindowsNetworkMetrics(
client: Client,
hostId?: number,
): Promise<{
interfaces: Array<{ interfaces: Array<{
name: string; name: string;
ip: string; ip: string;
@@ -213,27 +248,37 @@ async function collectWindowsNetworkMetrics(client: Client): Promise<{
}> = []; }> = [];
try { try {
const firstReadAt = Date.now(); const readAt = Date.now();
const before = await execPowerShell(client, WINDOWS_ADAPTER_SCRIPT); const result = await execPowerShell(client, WINDOWS_ADAPTER_SCRIPT);
await new Promise((resolve) => setTimeout(resolve, 500)); const currentRows = parseWindowsAdapterJson(result.stdout);
const after = await execPowerShell(client, WINDOWS_ADAPTER_SCRIPT); const previous =
const elapsedSeconds = (Date.now() - firstReadAt) / 1000; hostId !== undefined ? previousWindowsSamples.get(hostId) : undefined;
const elapsedSeconds = previous ? (readAt - previous.timestamp) / 1000 : 0;
const beforeRows = parseWindowsAdapterJson(before.stdout); const previousMap = new Map(
const afterMap = new Map( (previous?.rows ?? []).map((row) => [row.name, row]),
parseWindowsAdapterJson(after.stdout).map((row) => [row.name, row]),
); );
for (const row of beforeRows) { for (const row of currentRows) {
const afterRow = afterMap.get(row.name); const previousRow = previousMap.get(row.name);
interfaces.push({ interfaces.push({
name: row.name, name: row.name,
ip: row.ip, ip: row.ip,
state: row.state, state: row.state,
rxBytes: row.rx || null, rxBytes: row.rx || null,
txBytes: row.tx || null, txBytes: row.tx || null,
rxRateBps: counterRate(row.rx, afterRow?.rx, elapsedSeconds), rxRateBps: previousRow
txRateBps: counterRate(row.tx, afterRow?.tx, elapsedSeconds), ? counterRate(previousRow.rx, row.rx, elapsedSeconds)
: null,
txRateBps: previousRow
? counterRate(previousRow.tx, row.tx, elapsedSeconds)
: null,
});
}
if (hostId !== undefined) {
previousWindowsSamples.set(hostId, {
rows: currentRows,
timestamp: readAt,
}); });
} }
} catch { } catch {
@@ -246,6 +291,7 @@ async function collectWindowsNetworkMetrics(client: Client): Promise<{
export async function collectNetworkMetrics( export async function collectNetworkMetrics(
client: Client, client: Client,
platform?: HostPlatform, platform?: HostPlatform,
hostId?: number,
): Promise<{ ): Promise<{
interfaces: Array<{ interfaces: Array<{
name: string; name: string;
@@ -258,10 +304,10 @@ export async function collectNetworkMetrics(
}>; }>;
}> { }> {
if (platform === "darwin") { if (platform === "darwin") {
return collectDarwinNetworkMetrics(client); return collectDarwinNetworkMetrics(client, hostId);
} }
if (platform === "windows") { if (platform === "windows") {
return collectWindowsNetworkMetrics(client); return collectWindowsNetworkMetrics(client, hostId);
} }
const interfaces: Array<{ const interfaces: Array<{
@@ -318,13 +364,14 @@ export async function collectNetworkMetrics(
} }
try { try {
const firstReadAt = Date.now(); const readAt = Date.now();
const procNet = await execCommand(client, "cat /proc/net/dev"); const procNet = await execCommand(client, "cat /proc/net/dev");
await new Promise((resolve) => setTimeout(resolve, 500));
const procNetAfter = await execCommand(client, "cat /proc/net/dev");
const elapsedSeconds = (Date.now() - firstReadAt) / 1000;
const rxTxMap = parseNetworkCounters(procNet.stdout); const rxTxMap = parseNetworkCounters(procNet.stdout);
const afterMap = parseNetworkCounters(procNetAfter.stdout); const previous =
hostId !== undefined ? previousSamples.get(hostId) : undefined;
const elapsedSeconds = previous
? (readAt - previous.timestamp) / 1000
: 0;
if (ifMap.size === 0) { if (ifMap.size === 0) {
for (const name of rxTxMap.keys()) { for (const name of rxTxMap.keys()) {
if (name !== "lo") ifMap.set(name, { ip: "", state: "UNKNOWN" }); if (name !== "lo") ifMap.set(name, { ip: "", state: "UNKNOWN" });
@@ -332,15 +379,25 @@ export async function collectNetworkMetrics(
} }
for (const [name, data] of ifMap.entries()) { for (const [name, data] of ifMap.entries()) {
const rxTx = rxTxMap.get(name); const rxTx = rxTxMap.get(name);
const after = afterMap.get(name); const previousCounters = previous?.counters.get(name);
interfaces.push({ interfaces.push({
name, name,
ip: data.ip, ip: data.ip,
state: data.state, state: data.state,
rxBytes: rxTx?.rx ?? null, rxBytes: rxTx?.rx ?? null,
txBytes: rxTx?.tx ?? null, txBytes: rxTx?.tx ?? null,
rxRateBps: counterRate(rxTx?.rx, after?.rx, elapsedSeconds), rxRateBps: previousCounters
txRateBps: counterRate(rxTx?.tx, after?.tx, elapsedSeconds), ? counterRate(previousCounters.rx, rxTx?.rx, elapsedSeconds)
: null,
txRateBps: previousCounters
? counterRate(previousCounters.tx, rxTx?.tx, elapsedSeconds)
: null,
});
}
if (hostId !== undefined) {
previousSamples.set(hostId, {
counters: rxTxMap,
timestamp: readAt,
}); });
} }
} catch { } catch {