diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index ba11ca658fd..e2fcfe1b0f1 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -1236,6 +1236,7 @@ jobs: run: >- pnpm exec vitest run --config config/vitest.config.ts config/scripts/nsis-process-check.test.mjs + config/scripts/idle-cpu-process-sampling.test.mjs - name: Test Windows-specific boundaries run: >- diff --git a/config/scripts/idle-cpu-process-sampling.mjs b/config/scripts/idle-cpu-process-sampling.mjs index 81f9218e5ad..883b3c566bb 100644 --- a/config/scripts/idle-cpu-process-sampling.mjs +++ b/config/scripts/idle-cpu-process-sampling.mjs @@ -55,9 +55,26 @@ function readUnixProcesses() { return parseUnixProcesses(stdout) } +// `Win32_Process` times are 100 ns counts. Not `Number(value)`: that turns a missing +// field into a real-looking 0, so the CPU delta would silently go kernel-only. +function cimTimeSeconds(value) { + return typeof value === 'number' && Number.isFinite(value) ? value / 10_000_000 : null +} + +export function cimCpuSeconds(entry) { + const userModeSeconds = cimTimeSeconds(entry.UserModeTime) + const kernelModeSeconds = cimTimeSeconds(entry.KernelModeTime) + return userModeSeconds !== null && kernelModeSeconds !== null + ? userModeSeconds + kernelModeSeconds + : null +} + function readWindowsProcesses() { + // Why UTF-8: the default OEM code page best-fits characters such as `•` or `→` in any + // process's command line to raw control bytes (0x07, 0x1A), which JSON.parse rejects. const script = - 'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,WorkingSetSize,CommandLine | ConvertTo-Json -Compress' + '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; ' + + 'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,WorkingSetSize,UserModeTime,KernelModeTime,CommandLine | ConvertTo-Json -Compress' const result = spawnSync('powershell.exe', ['-NoProfile', '-Command', script], { encoding: 'utf8', maxBuffer: 20 * 1024 * 1024 @@ -71,7 +88,7 @@ function readWindowsProcesses() { pid: Number(entry.ProcessId), ppid: Number(entry.ParentProcessId), percentCpu: 0, - cpuTimeSeconds: null, + cpuTimeSeconds: cimCpuSeconds(entry), rssBytes: Number(entry.WorkingSetSize) || 0, command: String(entry.CommandLine || '') })) diff --git a/config/scripts/idle-cpu-process-sampling.test.mjs b/config/scripts/idle-cpu-process-sampling.test.mjs index d452c6e3ce0..2c25187ea9f 100644 --- a/config/scripts/idle-cpu-process-sampling.test.mjs +++ b/config/scripts/idle-cpu-process-sampling.test.mjs @@ -1,5 +1,10 @@ +import { spawn } from 'node:child_process' import { describe, expect, it } from 'vitest' -import { sampleProcessTreeUntilWorkloadsComplete } from './idle-cpu-process-sampling.mjs' +import { + cimCpuSeconds, + readProcessRows, + sampleProcessTreeUntilWorkloadsComplete +} from './idle-cpu-process-sampling.mjs' function createClock(workloadCompletesAt) { let currentMs = 0 @@ -27,6 +32,39 @@ function createClock(workloadCompletesAt) { return { now: () => currentMs, readRows, wait, workloadPromise } } +describe('Windows CIM CPU time mapping', () => { + it('converts the 100ns user and kernel times to a summed second count', () => { + // 10_000_000 and 25_000_000 units of 100ns are 1s and 2.5s. + expect(cimCpuSeconds({ UserModeTime: 10_000_000, KernelModeTime: 25_000_000 })).toBe(3.5) + }) + + it('maps a missing or non-numeric time to null instead of a kernel-only reading', () => { + expect(cimCpuSeconds({ UserModeTime: null, KernelModeTime: 10_000_000 })).toBeNull() + expect(cimCpuSeconds({ KernelModeTime: 10_000_000 })).toBeNull() + expect(cimCpuSeconds({ UserModeTime: '10000000', KernelModeTime: 10_000_000 })).toBeNull() + expect(cimCpuSeconds({ UserModeTime: Number.NaN, KernelModeTime: 10_000_000 })).toBeNull() + expect( + cimCpuSeconds({ UserModeTime: Number.POSITIVE_INFINITY, KernelModeTime: 10_000_000 }) + ).toBeNull() + }) +}) + +describe.skipIf(process.platform !== 'win32')('Windows process table read', () => { + it('parses a command line whose characters the OEM code page maps to control bytes', () => { + const marker = 'idle-cpu-oem • → ♪' + const child = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 30_000)', marker], { + stdio: 'ignore' + }) + try { + const row = readProcessRows().find((candidate) => candidate.pid === child.pid) + expect(row?.command).toContain(marker) + expect(row?.cpuTimeSeconds).toEqual(expect.any(Number)) + } finally { + child.kill() + } + }, 30_000) +}) + describe('idle CPU process sampling window', () => { it('extends through a slow workload and captures a final CPU delta', async () => { const clock = createClock(40) diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 149c49a2d11..e04946456e4 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -372,6 +372,7 @@ const WINDOWS_PACKAGE_TESTS = [ 'config/scripts/rebuild-native-deps-windows-process-tree.test.mjs', 'config/scripts/rebuild-native-deps-node-pty.test.mjs', 'config/scripts/nsis-process-check.test.mjs', + 'config/scripts/idle-cpu-process-sampling.test.mjs', 'config/scripts/ensure-native-runtime-job-ownership.test.mjs', 'config/scripts/verify-packaged-node-pty-job-ownership.test.mjs', 'config/scripts/windows-pe-machine.test.mjs', diff --git a/config/scripts/run-idle-cpu-benchmark.mjs b/config/scripts/run-idle-cpu-benchmark.mjs index 3892de8845d..eb3ebd3ea31 100644 --- a/config/scripts/run-idle-cpu-benchmark.mjs +++ b/config/scripts/run-idle-cpu-benchmark.mjs @@ -159,11 +159,17 @@ function buildAppIfNeeded(root, skipBuild) { throw new Error(`--skip-build requested, but ${mainPath} does not exist`) } console.log('[idle-cpu] building Electron app with electron-vite --mode e2e') - run('npx', ['electron-vite', 'build', '--mode', 'e2e'], { - cwd: root, - stdio: 'inherit', - env: { ...process.env, VITE_EXPOSE_STORE: 'true' } - }) + // Why through the wrapper: `npx` only resolves to an executable on POSIX; on Windows it is + // `npx.cmd`, which execFileSync cannot run, and the wrapper also verifies the boot graph. + run( + process.execPath, + [path.join(root, 'config', 'scripts', 'run-electron-vite-build.mjs'), '--mode', 'e2e'], + { + cwd: root, + stdio: 'inherit', + env: { ...process.env, VITE_EXPOSE_STORE: 'true' } + } + ) return mainPath } @@ -348,7 +354,7 @@ async function main() { return { error: added.error } } const store = window.__store - await store?.getState().fetchRepos() + await store?.getState().fetchRepos({ runtimeEnvironmentId: null }) const repo = store?.getState().repos.find((candidate) => candidate.id === added.repo.id) if (repo) { const detected = await store