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,
ConnectionStage,
} 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 {
collectDiskMetrics,
type DiskFilesystem,
} 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 { collectProcessesMetrics } from "./widgets/processes-collector.js";
import { collectSystemMetrics } from "./widgets/system-collector.js";
@@ -515,6 +521,8 @@ class PollingManager {
this.syncPollConcurrency();
this.statusStore.delete(host.id);
this.metricsStore.delete(host.id);
clearCpuSampleCache(host.id);
clearNetworkSampleCache(host.id);
return;
}
@@ -848,6 +856,8 @@ class PollingManager {
if (clearData) {
this.statusStore.delete(hostId);
this.metricsStore.delete(hostId);
clearCpuSampleCache(hostId);
clearNetworkSampleCache(hostId);
}
}
hostPollCache.invalidate(hostId);
@@ -917,6 +927,8 @@ class PollingManager {
if (!currentHostIds.has(hostId)) {
this.statusStore.delete(hostId);
this.metricsStore.delete(hostId);
clearCpuSampleCache(hostId);
clearNetworkSampleCache(hostId);
}
}
@@ -1771,7 +1783,7 @@ async function collectMetrics(
const collectFn = async (client: Client) => {
onAuthenticated?.();
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 disk = await collectDiskMetrics(
client,
@@ -1779,7 +1791,7 @@ async function collectMetrics(
monitoredMounts,
platform,
);
const network = await collectNetworkMetrics(client, platform);
const network = await collectNetworkMetrics(client, platform, host.id);
const uptime = await collectUptimeMetrics(client, platform);
const processes = await collectProcessesMetrics(client);
const system = await collectSystemMetrics(client, platform);
@@ -6,6 +6,24 @@ import {
type HostPlatform,
} 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(
cpuLine: string,
): { total: number; idle: number } | undefined {
@@ -130,6 +148,7 @@ async function collectWindowsCpuMetrics(client: Client): Promise<{
export async function collectCpuMetrics(
client: Client,
platform?: HostPlatform,
hostId?: number,
): Promise<{
percent: number | null;
cores: number | null;
@@ -147,7 +166,8 @@ export async function collectCpuMetrics(
let loadTriplet: [number, number, number] | null = null;
try {
const [stat1, loadAvgOut, coresOut] = await Promise.race([
const readAt = Date.now();
const [statOut, loadAvgOut, coresOut] = await Promise.race([
Promise.all([
execCommand(client, "cat /proc/stat"),
execCommand(client, "cat /proc/loadavg"),
@@ -164,24 +184,22 @@ export async function collectCpuMetrics(
),
]);
await new Promise((r) => setTimeout(r, 500));
const stat2 = await execCommand(client, "cat /proc/stat");
const cpuLine1 = (
stat1.stdout.split("\n").find((l) => l.startsWith("cpu ")) || ""
const cpuLine = (
statOut.stdout.split("\n").find((l) => l.startsWith("cpu ")) || ""
).trim();
const cpuLine2 = (
stat2.stdout.split("\n").find((l) => l.startsWith("cpu ")) || ""
).trim();
const a = parseCpuLine(cpuLine1);
const b = parseCpuLine(cpuLine2);
if (a && b) {
const totalDiff = b.total - a.total;
const idleDiff = b.idle - a.idle;
const current = parseCpuLine(cpuLine);
const previous =
hostId !== undefined ? previousStat.get(hostId) : undefined;
if (current && previous) {
const totalDiff = current.total - previous.total;
const idleDiff = current.idle - previous.idle;
const used = totalDiff - idleDiff;
if (totalDiff > 0)
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+/);
if (laParts.length >= 3) {
@@ -44,6 +44,31 @@ export function counterRate(
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(
output: string,
): Map<string, { ip: string; state: string }> {
@@ -85,7 +110,10 @@ export function parseDarwinNetstat(
return counters;
}
async function collectDarwinNetworkMetrics(client: Client): Promise<{
async function collectDarwinNetworkMetrics(
client: Client,
hostId?: number,
): Promise<{
interfaces: Array<{
name: string;
ip: string;
@@ -111,31 +139,35 @@ async function collectDarwinNetworkMetrics(client: Client): Promise<{
const ifMap = parseDarwinIfconfig(ifconfigOut.stdout);
try {
const firstReadAt = Date.now();
const netstat1 = await execCommand(client, "netstat -ib 2>/dev/null");
await new Promise((resolve) => setTimeout(resolve, 500));
const netstat2 = await execCommand(client, "netstat -ib 2>/dev/null");
const elapsedSeconds = (Date.now() - firstReadAt) / 1000;
const before = parseDarwinNetstat(netstat1.stdout);
const after = parseDarwinNetstat(netstat2.stdout);
const readAt = Date.now();
const netstatOut = await execCommand(client, "netstat -ib 2>/dev/null");
const current = parseDarwinNetstat(netstatOut.stdout);
const previous =
hostId !== undefined ? previousSamples.get(hostId) : undefined;
const elapsedSeconds = previous
? (readAt - previous.timestamp) / 1000
: 0;
if (ifMap.size === 0) {
for (const name of before.keys()) {
for (const name of current.keys()) {
ifMap.set(name, { ip: "", state: "UNKNOWN" });
}
}
for (const [name, data] of ifMap.entries()) {
const b = before.get(name);
const a = after.get(name);
const c = current.get(name);
const p = previous?.counters.get(name);
interfaces.push({
name,
ip: data.ip,
state: data.state,
rxBytes: b?.rx ?? null,
txBytes: b?.tx ?? null,
rxRateBps: counterRate(b?.rx, a?.rx, elapsedSeconds),
txRateBps: counterRate(b?.tx, a?.tx, elapsedSeconds),
rxBytes: c?.rx ?? null,
txBytes: c?.tx ?? null,
rxRateBps: p ? counterRate(p.rx, c?.rx, elapsedSeconds) : null,
txRateBps: p ? counterRate(p.tx, c?.tx, elapsedSeconds) : null,
});
}
if (hostId !== undefined) {
previousSamples.set(hostId, { counters: current, timestamp: readAt });
}
} catch {
for (const [name, data] of ifMap.entries()) {
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<{
name: string;
ip: string;
@@ -213,27 +248,37 @@ async function collectWindowsNetworkMetrics(client: Client): Promise<{
}> = [];
try {
const firstReadAt = Date.now();
const before = await execPowerShell(client, WINDOWS_ADAPTER_SCRIPT);
await new Promise((resolve) => setTimeout(resolve, 500));
const after = await execPowerShell(client, WINDOWS_ADAPTER_SCRIPT);
const elapsedSeconds = (Date.now() - firstReadAt) / 1000;
const beforeRows = parseWindowsAdapterJson(before.stdout);
const afterMap = new Map(
parseWindowsAdapterJson(after.stdout).map((row) => [row.name, row]),
const readAt = Date.now();
const result = await execPowerShell(client, WINDOWS_ADAPTER_SCRIPT);
const currentRows = parseWindowsAdapterJson(result.stdout);
const previous =
hostId !== undefined ? previousWindowsSamples.get(hostId) : undefined;
const elapsedSeconds = previous ? (readAt - previous.timestamp) / 1000 : 0;
const previousMap = new Map(
(previous?.rows ?? []).map((row) => [row.name, row]),
);
for (const row of beforeRows) {
const afterRow = afterMap.get(row.name);
for (const row of currentRows) {
const previousRow = previousMap.get(row.name);
interfaces.push({
name: row.name,
ip: row.ip,
state: row.state,
rxBytes: row.rx || null,
txBytes: row.tx || null,
rxRateBps: counterRate(row.rx, afterRow?.rx, elapsedSeconds),
txRateBps: counterRate(row.tx, afterRow?.tx, elapsedSeconds),
rxRateBps: previousRow
? 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 {
@@ -246,6 +291,7 @@ async function collectWindowsNetworkMetrics(client: Client): Promise<{
export async function collectNetworkMetrics(
client: Client,
platform?: HostPlatform,
hostId?: number,
): Promise<{
interfaces: Array<{
name: string;
@@ -258,10 +304,10 @@ export async function collectNetworkMetrics(
}>;
}> {
if (platform === "darwin") {
return collectDarwinNetworkMetrics(client);
return collectDarwinNetworkMetrics(client, hostId);
}
if (platform === "windows") {
return collectWindowsNetworkMetrics(client);
return collectWindowsNetworkMetrics(client, hostId);
}
const interfaces: Array<{
@@ -318,13 +364,14 @@ export async function collectNetworkMetrics(
}
try {
const firstReadAt = Date.now();
const readAt = Date.now();
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 afterMap = parseNetworkCounters(procNetAfter.stdout);
const previous =
hostId !== undefined ? previousSamples.get(hostId) : undefined;
const elapsedSeconds = previous
? (readAt - previous.timestamp) / 1000
: 0;
if (ifMap.size === 0) {
for (const name of rxTxMap.keys()) {
if (name !== "lo") ifMap.set(name, { ip: "", state: "UNKNOWN" });
@@ -332,15 +379,25 @@ export async function collectNetworkMetrics(
}
for (const [name, data] of ifMap.entries()) {
const rxTx = rxTxMap.get(name);
const after = afterMap.get(name);
const previousCounters = previous?.counters.get(name);
interfaces.push({
name,
ip: data.ip,
state: data.state,
rxBytes: rxTx?.rx ?? null,
txBytes: rxTx?.tx ?? null,
rxRateBps: counterRate(rxTx?.rx, after?.rx, elapsedSeconds),
txRateBps: counterRate(rxTx?.tx, after?.tx, elapsedSeconds),
rxRateBps: previousCounters
? 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 {