|
| 1 | +/** |
| 2 | + * @fileoverview Detect whether the host is currently on AC power |
| 3 | + * (vs battery). Used by long-running build/test scripts to size |
| 4 | + * timeouts adaptively — laptops on battery throttle CPU hard |
| 5 | + * (especially macOS), and a static timeout that fits AC will kill |
| 6 | + * an otherwise-healthy run on battery. |
| 7 | + * |
| 8 | + * Two paths, in priority order: |
| 9 | + * |
| 10 | + * 1. `node:smol-power` — when running inside a node-smol binary |
| 11 | + * that ships the smol_power native binding (socket-btm's custom |
| 12 | + * Node distribution). Pure C++ syscalls, sub-millisecond. |
| 13 | + * |
| 14 | + * 2. Shellout fallback — system Node doesn't have node:smol-power. |
| 15 | + * Each platform has a different mechanism: |
| 16 | + * * macOS: `pmset -g batt` parses "AC Power" / "Battery Power" |
| 17 | + * * Linux: reads /sys/class/power_supply/<entry>/online |
| 18 | + * (no shellout, just open/read syscalls) |
| 19 | + * * Windows: PowerShell `Get-CimInstance Win32_Battery` |
| 20 | + * |
| 21 | + * On detection failure we conservatively assume AC — the downstream |
| 22 | + * timeout becomes the shorter / more aggressive value, which is |
| 23 | + * appropriate for build servers and headless CI (those environments |
| 24 | + * are expected to run at full speed). |
| 25 | + * |
| 26 | + * Returns a Promise so callers don't block the event loop on shellout |
| 27 | + * paths. |
| 28 | + * |
| 29 | + * Byte-identical across the fleet via socket-repo-template's |
| 30 | + * sync-scaffolding (IDENTICAL_FILES). |
| 31 | + */ |
| 32 | + |
| 33 | +import { existsSync, promises as fs } from 'node:fs' |
| 34 | +import path from 'node:path' |
| 35 | +import process from 'node:process' |
| 36 | + |
| 37 | +import { spawn } from '@socketsecurity/lib/spawn' |
| 38 | + |
| 39 | +// Probe for node:smol-power. Lives in socket-btm's node-smol binary. |
| 40 | +// Wrapped in try/catch so this file is safe to import on system Node |
| 41 | +// where the module doesn't exist. |
| 42 | +let _smolPower: { isOnAcPower: () => boolean } | undefined |
| 43 | +async function getSmolPower(): Promise<typeof _smolPower> { |
| 44 | + if (_smolPower !== undefined) { |
| 45 | + return _smolPower |
| 46 | + } |
| 47 | + try { |
| 48 | + const mod = await import('node:smol-power') |
| 49 | + _smolPower = mod |
| 50 | + return _smolPower |
| 51 | + } catch { |
| 52 | + _smolPower = undefined |
| 53 | + return undefined |
| 54 | + } |
| 55 | +} |
| 56 | + |
| 57 | +async function detectMacOs(): Promise<boolean> { |
| 58 | + try { |
| 59 | + // `pmset -g batt` on macOS prints lines like |
| 60 | + // Now drawing from 'AC Power' |
| 61 | + // Now drawing from 'Battery Power' |
| 62 | + // Match the AC variant; everything else (battery, unknown) is |
| 63 | + // treated as not-AC. |
| 64 | + const result = await spawn('pmset', ['-g', 'batt'], { |
| 65 | + stdio: ['ignore', 'pipe', 'ignore'], |
| 66 | + }) |
| 67 | + return /AC Power/.test(result.stdout || '') |
| 68 | + } catch { |
| 69 | + return true |
| 70 | + } |
| 71 | +} |
| 72 | + |
| 73 | +async function detectLinux(): Promise<boolean> { |
| 74 | + // Linux exposes power state under /sys/class/power_supply. Each |
| 75 | + // AC adapter is its own dir (`AC`, `ADP1`, `AC0`, `ACAD`, …) |
| 76 | + // with an `online` file holding "1" when power is connected. |
| 77 | + // Containers and headless servers often have no power_supply |
| 78 | + // tree at all — treat that as AC since those environments are |
| 79 | + // expected to run at full speed. |
| 80 | + const psDir = '/sys/class/power_supply' |
| 81 | + if (!existsSync(psDir)) { |
| 82 | + return true |
| 83 | + } |
| 84 | + try { |
| 85 | + const entries = await fs.readdir(psDir) |
| 86 | + for (const entry of entries) { |
| 87 | + const onlineFile = path.join(psDir, entry, 'online') |
| 88 | + if (!existsSync(onlineFile)) { |
| 89 | + continue |
| 90 | + } |
| 91 | + try { |
| 92 | + const value = await fs.readFile(onlineFile, 'utf8') |
| 93 | + if (value.trim() === '1') { |
| 94 | + return true |
| 95 | + } |
| 96 | + } catch { |
| 97 | + // Unreadable entry — skip; another entry may report. |
| 98 | + } |
| 99 | + } |
| 100 | + } catch { |
| 101 | + // Directory enumeration failed — fall through to AC. |
| 102 | + return true |
| 103 | + } |
| 104 | + return false |
| 105 | +} |
| 106 | + |
| 107 | +async function detectWindows(): Promise<boolean> { |
| 108 | + try { |
| 109 | + // Windows: query the battery status via PowerShell + CIM. |
| 110 | + // `Win32_Battery.BatteryStatus`: |
| 111 | + // 1 = Discharging (battery) |
| 112 | + // 2 = On AC, not charging or fully charged |
| 113 | + // 3..5 = Various battery states |
| 114 | + // 6 = AC + charging |
| 115 | + // Desktops with no battery return an empty result; treat as AC. |
| 116 | + const result = await spawn( |
| 117 | + 'powershell.exe', |
| 118 | + [ |
| 119 | + '-NoProfile', |
| 120 | + '-Command', |
| 121 | + '(Get-CimInstance -ClassName Win32_Battery).BatteryStatus', |
| 122 | + ], |
| 123 | + { stdio: ['ignore', 'pipe', 'ignore'] }, |
| 124 | + ) |
| 125 | + const trimmed = (result.stdout || '').trim() |
| 126 | + if (trimmed === '') { |
| 127 | + return true |
| 128 | + } |
| 129 | + const status = Number.parseInt(trimmed, 10) |
| 130 | + if (Number.isNaN(status)) { |
| 131 | + return true |
| 132 | + } |
| 133 | + return status === 2 || status === 6 |
| 134 | + } catch { |
| 135 | + return true |
| 136 | + } |
| 137 | +} |
| 138 | + |
| 139 | +/** |
| 140 | + * Returns `true` if the host is on AC power. Conservative on |
| 141 | + * detection failure (returns `true`) — callers using this for |
| 142 | + * timeout sizing prefer a longer timeout to a too-short one. |
| 143 | + * |
| 144 | + * Prefers the native binding (`node:smol-power`) when running |
| 145 | + * inside a node-smol binary; falls back to a per-platform path |
| 146 | + * (shellout on macOS / Windows, direct sysfs reads on Linux) on |
| 147 | + * system Node. |
| 148 | + */ |
| 149 | +export async function isOnAcPower(): Promise<boolean> { |
| 150 | + const native = await getSmolPower() |
| 151 | + if (native) { |
| 152 | + return native.isOnAcPower() |
| 153 | + } |
| 154 | + if (process.platform === 'darwin') { |
| 155 | + return await detectMacOs() |
| 156 | + } |
| 157 | + if (process.platform === 'linux') { |
| 158 | + return await detectLinux() |
| 159 | + } |
| 160 | + if (process.platform === 'win32') { |
| 161 | + return await detectWindows() |
| 162 | + } |
| 163 | + // Unsupported platform; conservative default. |
| 164 | + return true |
| 165 | +} |
0 commit comments