diff --git a/src/backend/session.ts b/src/backend/session.ts index 1367684..cf0b733 100644 --- a/src/backend/session.ts +++ b/src/backend/session.ts @@ -72,7 +72,19 @@ const PROCESS_CHANNEL_OPEN_TIMEOUT_MS = 15_000; const PROCESS_MAX_BUFFER_BYTES = 2 * 1024 * 1024; const PROCESS_SNAPSHOT_MARKER = '__CF_WEBSSH_TOP_SNAPSHOT__'; // Octal escapes keep the delimiter itself out of the command line shown by top. -const PROCESS_MONITOR_COMMAND = "LC_ALL=C LANG=C sh -c 'while :; do printf \"\\137\\137CF_WEBSSH_TOP_SNAPSHOT\\137\\137\\n\"; top -b -n 1 -w 512 2>/dev/null || top -b -n 1 2>/dev/null || exit 127; sleep 2; done'"; +// `top` flags differ across platforms: Linux procps uses `-n 1` (1 iteration) + `-c` (full command line), +// but on FreeBSD `-n 1` means "show 1 process" and `-w` is unsupported — so the previous +// Linux-only fallback chain silently truncated FreeBSD output to a single process row. +// `-c` is required on Linux/macOS because their top defaults to showing just the program name; +// FreeBSD top shows the full command line by default. +// Width handling: without a tty, top (both procps and busybox) defaults to a narrow width and +// truncates long command lines with a '+' marker. procps supports `-w 0` (unlimited); busybox +// rejects `-w` and falls back to the plain form, but honors the COLUMNS env var — so we export +// COLUMNS=4096 and use a fallback chain on Linux to cover both implementations. +// Linux: top -b -c -n 1 -w 0 || top -b -c -n 1 || top -b -n 1 +// FreeBSD: top -b -a -d 1 (batch, all processes, 1 iteration) +// macOS: top -l 1 -c -n 0 -s 0 (1 log sample, full command line, all processes, no delay) +const PROCESS_MONITOR_COMMAND = "LC_ALL=C LANG=C sh -c 'os=$(uname -s 2>/dev/null || echo Linux); export COLUMNS=4096; while :; do printf \"\\137\\137CF_WEBSSH_TOP_SNAPSHOT\\137\\137\\n\"; case \"$os\" in FreeBSD) top -b -a -d 1 2>/dev/null || exit 127;; Darwin) top -l 1 -c -n 0 -s 0 2>/dev/null || exit 127;; *) top -b -c -n 1 -w 0 2>/dev/null || top -b -c -n 1 2>/dev/null || top -b -n 1 2>/dev/null || exit 127;; esac; sleep 2; done'"; const KEEPALIVE_NAME = new TextEncoder().encode('keepalive@openssh.com'); export class SSHSession { diff --git a/src/backend/top-parser.ts b/src/backend/top-parser.ts index a5ed431..91d6448 100644 --- a/src/backend/top-parser.ts +++ b/src/backend/top-parser.ts @@ -31,21 +31,37 @@ export interface ProcessSnapshot { const ANSI_ESCAPE_RE = /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))/g; const MAX_PROCESSES = 512; -function finiteNumber(value: string | undefined): number | null { +// Matches a single numeric token that may use ',' or '.' as decimal separator. +const NUMBER_TOKEN_RE = /\d+(?:[.,]\d+)?/; + +function finiteNumber(value: string | undefined | null): number | null { if (!value) return null; const parsed = Number(value.replace(',', '.').replace(/%$/, '')); return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; } +// Parses "10,23" / "10.23" / "10" -> number. Returns null on invalid input. +function parseDecimal(token: string | undefined | null): number | null { + if (!token) return null; + if (!NUMBER_TOKEN_RE.test(token)) return null; + // Ensure the whole token is a number (e.g. reject "10abc"). + if (!/^\d+(?:[.,]\d+)?$/.test(token)) return null; + return Number(token.replace(',', '.')); +} + function clampPercent(value: number): number { return Math.min(100, Math.max(0, value)); } function parseLoad(lines: string[]): [number, number, number] | null { for (const line of lines) { - const match = line.match(/load average:\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/i); - if (!match) continue; - const values = match.slice(1, 4).map((value) => finiteNumber(value)); + // Linux: "load average: 10.23, 11.92, 12.63" + // FreeBSD: "load averages: 10,23, 11,92, 12,63" + const prefix = line.match(/load averages?\s*:\s*(.+)/i); + if (!prefix) continue; + const tokens = prefix[1].match(/\d+(?:[.,]\d+)?/g); + if (!tokens || tokens.length < 3) continue; + const values = tokens.slice(0, 3).map(parseDecimal); if (values.every((value): value is number => value !== null)) return values as [number, number, number]; } return null; @@ -53,50 +69,148 @@ function parseLoad(lines: string[]): [number, number, number] | null { function parseCPU(lines: string[]): number | null { for (const line of lines) { - if (!/(?:^|\s)(?:%?Cpu\(s\)|CPU):/i.test(line)) continue; - const idle = line.match(/([\d.]+)\s*%?\s*(?:id|idle)\b/i); - const idlePercent = finiteNumber(idle?.[1]); + // Linux: "%Cpu(s): 13.1 us, 0.7 sy, 6.4 ni, 79.6 id, ..." + // FreeBSD: "CPU: 13,1% user, 0,7% nice, 6,4% system, 0,1% interrupt, 79,6% idle" + // macOS: "CPU usage: 13% user, 6% sys, 79% idle" + if (!/(?:^|\s)(?:%?Cpu\(s\)|CPU(?:\s+usage)?|%CPU)\s*:/i.test(line)) continue; + const idle = line.match(/([\d.,]+)\s*%?\s*(?:id|idle)\b/i); + const idlePercent = parseDecimal(idle?.[1]); if (idlePercent !== null) return clampPercent(100 - idlePercent); + // Fallback: sum user + sys (macOS sometimes omits idle). + const user = line.match(/([\d.,]+)\s*%?\s*(?:us(?:er)?|user)\b/i); + const sys = line.match(/([\d.,]+)\s*%?\s*(?:sy(?:s|stem)?|system)\b/i); + const userVal = parseDecimal(user?.[1]); + const sysVal = parseDecimal(sys?.[1]); + if (userVal !== null && sysVal !== null) return clampPercent(userVal + sysVal); } return null; } function unitMultiplier(label: string | undefined): number { const unit = label?.toLowerCase() ?? ''; + if (unit.startsWith('t')) return 1024 ** 4; if (unit.startsWith('g')) return 1024 ** 3; if (unit.startsWith('m')) return 1024 ** 2; if (unit.startsWith('k')) return 1024; return 1; } +// Parses "24G" / "3196K" / "5120M" / "10" -> bytes. Suffix is a single letter (k/m/g/t). +function parseSizeToken(token: string): number | null { + const match = token.match(/^([\d.,]+)\s*([kmgtpe]?)$/i); + if (!match) return null; + const value = parseDecimal(match[1]); + if (value === null) return null; + const suffix = match[2].toLowerCase(); + const power = suffix ? 'kmgtpe'.indexOf(suffix) + 1 : 0; + return Math.round(value * (1024 ** power)); +} + function parseUsage(lines: string[], kind: 'Mem' | 'Swap'): ResourceUsage | null { for (const line of lines) { + // macOS PhysMem (only valid for Mem). + if (kind === 'Mem' && /^\s*PhysMem\s*:/i.test(line)) { + const result = parseMacOSPhysMem(line); + if (result) return result; + continue; + } if (!new RegExp(`(?:^|\\s)(?:KiB|MiB|GiB)?\\s*${kind}\\s*:`, 'i').test(line)) continue; - const unit = line.match(/(?:^|\s)(KiB|MiB|GiB)\s*(?:Mem|Swap)\s*:/i)?.[1]; - const multiplier = unitMultiplier(unit ?? 'KiB'); - const amount = (label: 'total' | 'used' | 'free'): number | null => { - const match = line.match(new RegExp(`([\\d.]+)\\s*([kmgt]?)\\s+${label}\\b`, 'i')); - const value = finiteNumber(match?.[1]); - return value === null ? null : value * (match?.[2] ? unitMultiplier(match[2]) : multiplier); - }; - const total = amount('total'); - const used = amount('used'); - const free = amount('free'); - const resolvedTotal = total ?? (used !== null && free !== null ? used + free : null); - if (resolvedTotal === null || used === null || resolvedTotal < 0) continue; - const totalBytes = Math.round(resolvedTotal); - const usedBytes = Math.min(totalBytes, Math.round(used)); - return { - usedBytes, - totalBytes, - percent: totalBytes > 0 ? clampPercent((usedBytes / totalBytes) * 100) : 0, - }; + + // Try the explicit total/used/free labelled form first (Linux + FreeBSD Swap). + const labelled = parseLabelledUsage(line); + if (labelled) return labelled; + + // Then the FreeBSD category form: "Mem: 24G Active, 22G Inact, 18G Laundry, 51G Wired, 10G Free". + if (kind === 'Mem') { + const categorised = parseFreeBSDMemUsage(line); + if (categorised) return categorised; + } } return null; } +// Handles "X total, Y used, Z free" style lines (Linux, FreeBSD Swap). +function parseLabelledUsage(line: string): ResourceUsage | null { + const unit = line.match(/(?:^|\s)(KiB|MiB|GiB)\s*(?:Mem|Swap)\s*:/i)?.[1]; + const defaultMultiplier = unit ? unitMultiplier(unit) : 1; + + const amount = (label: string): number | null => { + const match = line.match(new RegExp(`([\\d.,]+)\\s*([kmgtpe]i?b?|[kmgtpe]?)\\s+${label}\\b`, 'i')); + if (!match) return null; + const value = parseDecimal(match[1]); + if (value === null) return null; + const suffix = match[2]; + const mult = suffix ? unitMultiplier(suffix) : defaultMultiplier; + return value * mult; + }; + + const total = amount('total'); + const used = amount('used'); + const free = amount('free'); + const resolvedTotal = total ?? (used !== null && free !== null ? used + free : null); + if (resolvedTotal === null || used === null || resolvedTotal < 0) return null; + const totalBytes = Math.round(resolvedTotal); + const usedBytes = Math.min(totalBytes, Math.round(used)); + return { + usedBytes, + totalBytes, + percent: totalBytes > 0 ? clampPercent((usedBytes / totalBytes) * 100) : 0, + }; +} + +// Handles FreeBSD Mem: "24G Active, 22G Inact, 18G Laundry, 51G Wired, 10G Free". +// total = sum of all listed categories; used = total - free. +function parseFreeBSDMemUsage(line: string): ResourceUsage | null { + const memLabels = new Set(['active', 'inact', 'laundry', 'wired', 'buf', 'cache', 'free', 'laund']); + // Match "