Stop battery EcoQoS from starving frame capture during recording
Template tests / tests (push) Successful in 1m41s
Template tests / tests (push) Successful in 1m41s
Confirmed cause: on battery in a power-saving plan, Windows applies Power Throttling (EcoQoS) to background work. StepForge records with its window hidden, so the frame-capture worker renderer (plus the GPU and screen-capture utility processes feeding it) get CPU-throttled. The throttled worker can't sample the screen fast enough, so every click finds no fresh pre-click frame and falls back to a slow post-click fresh shot — producing out-of-order, dropped steps. It only reproduced on battery; plugged in (where EcoQoS stays off even in eco mode) it worked. The capture log showed every click detected but every one reporting "no frame qualified", with a ~4.6s stream startup. Fixes: - Chromium switches (disable-background-timer-throttling, disable-renderer-backgrounding, disable-backgrounding-occluded-windows) so Chromium stops de-prioritising and timer-throttling the hidden worker. - New app/win-power.js opts the OS processes out of EcoQoS via SetProcessInformation(ProcessPowerThrottling, EXECUTION_SPEED off) and raises them to high priority. Applied to all live Electron processes when a session starts/resumes, and to the worker renderer the moment it is created (before it begins streaming). Best-effort, no-op off Windows. The earlier mouse-hook EcoQoS opt-out already fixed click *detection*; this fixes the frame-capture side that the same throttling was breaking.
This commit is contained in:
+25
@@ -19,6 +19,19 @@ const { exportGuideArchive, importGuideArchive, saveLinkedGuide, readArchive } =
|
||||
const { createSnapshot, listSnapshots, restoreSnapshot } = require('../core/snapshots');
|
||||
const { readLock } = require('../core/locks');
|
||||
const CaptureService = require('./capture');
|
||||
const { keepProcessesResponsive } = require('./win-power');
|
||||
|
||||
// Keep capture working on battery. In a power-saving plan on DC power, Windows
|
||||
// applies Power Throttling (EcoQoS) to background work — and StepForge records
|
||||
// with its window hidden, so the frame-capture worker renderer is exactly the
|
||||
// kind of "background" process the OS slows down. A throttled worker can't
|
||||
// sample the screen fast enough, so every click finds no fresh frame and the
|
||||
// recording falls apart (the bug only ever reproduced on battery). These
|
||||
// switches stop Chromium from de-prioritising and timer-throttling the hidden
|
||||
// worker; win-power.js additionally opts the OS processes out of EcoQoS.
|
||||
app.commandLine.appendSwitch('disable-background-timer-throttling');
|
||||
app.commandLine.appendSwitch('disable-renderer-backgrounding');
|
||||
app.commandLine.appendSwitch('disable-backgrounding-occluded-windows');
|
||||
|
||||
/**
|
||||
* StepForge main process. Zero network code: no telemetry, no updates, no
|
||||
@@ -510,16 +523,28 @@ function setupIpc() {
|
||||
}
|
||||
};
|
||||
|
||||
// Opt every live Electron process (browser, GPU, the screen-capture utility,
|
||||
// any renderers) out of EcoQoS for the duration of a recording. The hidden
|
||||
// capture-worker renderer is created later, during warmup, so it opts itself
|
||||
// out separately (see stream-backend.js); this covers the rest.
|
||||
const keepCaptureProcessesResponsive = () => {
|
||||
try {
|
||||
keepProcessesResponsive(app.getAppMetrics().map((m) => m.pid));
|
||||
} catch { /* metrics unavailable — best effort */ }
|
||||
};
|
||||
|
||||
h('capture:session', async ({ action, guideId, intervalSec }) => {
|
||||
if (action === 'start') {
|
||||
capture.startSession(guideId, { intervalSec: intervalSec ?? null });
|
||||
startCapturePower();
|
||||
keepCaptureProcessesResponsive();
|
||||
} else if (action === 'pause') {
|
||||
capture.togglePause(true);
|
||||
stopCapturePower();
|
||||
} else if (action === 'resume') {
|
||||
capture.togglePause(false);
|
||||
startCapturePower();
|
||||
keepCaptureProcessesResponsive();
|
||||
} else if (action === 'finish') {
|
||||
capture.finishSession();
|
||||
stopCapturePower();
|
||||
|
||||
@@ -340,6 +340,14 @@ async function createElectronHost(onEvent) {
|
||||
if (!win.isDestroyed()) win.destroy();
|
||||
throw err;
|
||||
}
|
||||
// The worker is a hidden renderer, so on battery Windows would EcoQoS-throttle
|
||||
// it and starve frame sampling. Clear that on its own OS process before it
|
||||
// starts streaming. Best-effort; no-op off Windows.
|
||||
try {
|
||||
// eslint-disable-next-line global-require
|
||||
const { keepProcessesResponsive } = require('./win-power');
|
||||
if (!win.isDestroyed()) keepProcessesResponsive([win.webContents.getOSProcessId()]);
|
||||
} catch { /* throttling tweak is optional */ }
|
||||
return {
|
||||
send(msg) {
|
||||
if (!win.isDestroyed()) win.webContents.send('capture-worker:command', msg);
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
'use strict';
|
||||
|
||||
const { spawn } = require('node:child_process');
|
||||
|
||||
/**
|
||||
* Opt a set of OS processes out of Windows Power Throttling (EcoQoS) and raise
|
||||
* them to high priority, so the capture pipeline keeps running at full CPU
|
||||
* speed while the laptop is on battery in a power-saving plan.
|
||||
*
|
||||
* Why this exists: during a recording StepForge hides its window, so Windows
|
||||
* treats the frame-capture worker renderer (and the GPU / screen-capture
|
||||
* utility processes feeding it) as background work and CPU-throttles them on
|
||||
* DC power. Throttled, the worker can't sample frames fast enough — every
|
||||
* click then finds no fresh pre-click frame and falls back to a slow
|
||||
* post-click shot, which is what broke recordings on battery only.
|
||||
*
|
||||
* The Chromium command-line switches in main.js stop Chromium's own
|
||||
* backgrounding; this goes one level lower and clears the OS EcoQoS flag via
|
||||
* SetProcessInformation(ProcessPowerThrottling, EXECUTION_SPEED → off), which
|
||||
* has no Node binding, so we drive it through a short-lived PowerShell.
|
||||
*
|
||||
* Best-effort and fire-and-forget: any failure (older Windows without the API,
|
||||
* PowerShell blocked by policy, a process that already exited) is swallowed —
|
||||
* the Chromium switches still apply and capture degrades gracefully.
|
||||
*
|
||||
* No-op on every non-Windows platform.
|
||||
*
|
||||
* @param {number[]} pids OS process ids to keep responsive.
|
||||
*/
|
||||
function keepProcessesResponsive(pids) {
|
||||
if (process.platform !== 'win32') return;
|
||||
// Only integers reach the script, so the interpolation below can't inject.
|
||||
const list = [...new Set((pids || []).filter((p) => Number.isInteger(p) && p > 0))];
|
||||
if (!list.length) return;
|
||||
|
||||
const ps = `
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
Add-Type -TypeDefinition @'
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
public static class SFPower {
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
struct PROCESS_POWER_THROTTLING_STATE { public uint Version; public uint ControlMask; public uint StateMask; }
|
||||
|
||||
const int ProcessPowerThrottling = 4;
|
||||
const uint PROCESS_POWER_THROTTLING_CURRENT_VERSION = 1;
|
||||
const uint PROCESS_POWER_THROTTLING_EXECUTION_SPEED = 0x1;
|
||||
const uint HIGH_PRIORITY_CLASS = 0x00000080;
|
||||
const uint PROCESS_SET_INFORMATION = 0x0200;
|
||||
const uint PROCESS_QUERY_INFORMATION = 0x0400;
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError=true)]
|
||||
static extern IntPtr OpenProcess(uint access, bool inherit, uint pid);
|
||||
[DllImport("kernel32.dll", SetLastError=true)]
|
||||
static extern bool SetProcessInformation(IntPtr h, int cls, ref PROCESS_POWER_THROTTLING_STATE info, uint size);
|
||||
[DllImport("kernel32.dll")]
|
||||
static extern bool SetPriorityClass(IntPtr h, uint cls);
|
||||
[DllImport("kernel32.dll")]
|
||||
static extern bool CloseHandle(IntPtr h);
|
||||
|
||||
public static void Apply(uint pid) {
|
||||
IntPtr h = OpenProcess(PROCESS_SET_INFORMATION | PROCESS_QUERY_INFORMATION, false, pid);
|
||||
if (h == IntPtr.Zero) return;
|
||||
try {
|
||||
PROCESS_POWER_THROTTLING_STATE s = new PROCESS_POWER_THROTTLING_STATE();
|
||||
s.Version = PROCESS_POWER_THROTTLING_CURRENT_VERSION;
|
||||
s.ControlMask = PROCESS_POWER_THROTTLING_EXECUTION_SPEED;
|
||||
s.StateMask = 0; // 0 => throttling off => opt out of EcoQoS
|
||||
SetProcessInformation(h, ProcessPowerThrottling, ref s, (uint)Marshal.SizeOf(s));
|
||||
SetPriorityClass(h, HIGH_PRIORITY_CLASS);
|
||||
} finally { CloseHandle(h); }
|
||||
}
|
||||
}
|
||||
'@
|
||||
${list.map((p) => `[SFPower]::Apply(${p})`).join('\n')}
|
||||
`;
|
||||
|
||||
try {
|
||||
const child = spawn(
|
||||
'powershell.exe',
|
||||
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', ps],
|
||||
{ stdio: 'ignore', windowsHide: true },
|
||||
);
|
||||
child.on('error', () => { /* PowerShell missing/blocked — best effort */ });
|
||||
child.unref();
|
||||
} catch {
|
||||
// spawn itself failed; the Chromium switches remain in effect.
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { keepProcessesResponsive };
|
||||
Reference in New Issue
Block a user