Skip to content

Commit f1b4e22

Browse files
committed
chore: sync power-state helper from socket-repo-template@c23dfef
Adds `scripts/power-state.mts` — fleet-canonical AC-vs-battery detection helper. Used by long-running build/test scripts to size their timeouts adaptively (laptops on battery throttle CPU hard, and a static timeout tuned for AC will kill an otherwise-healthy run on battery). Tries `node:smol-power` first (when running inside a node-smol binary), falls back to per-platform paths on system Node: - macOS: `pmset -g batt` - Linux: `/sys/class/power_supply/<entry>/online` direct reads (no shellout, no D-Bus, no UPower) - Windows: PowerShell `Win32_Battery.BatteryStatus` Synced byte-identical from socket-repo-template via sync-scaffolding.
1 parent 2692586 commit f1b4e22

1 file changed

Lines changed: 165 additions & 0 deletions

File tree

scripts/power-state.mts

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
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

Comments
 (0)