fix(cli): hide Windows child process consoles

Add windowsHide: true to all 16 issue-scoped Studio server, lint, and CLI child-process calls so Windows console-subsystem children do not flash visible consoles.

Regression tests mock child_process and inspect the actual options passed at each scoped call site. #3476 detached Node telemetry and #3430 Chrome headless shell remain intentionally out of scope.

Co-authored-by: miguel.sierra <229591595+miguel-heygen@users.noreply.github.com>
This commit is contained in:
heygengenesis[bot]
2026-08-27 22:45:11 +00:00
committed by GitHub
co-authored by miguel.sierra
parent 097d901d70
commit 1d4dd0e352
23 changed files with 312 additions and 37 deletions
@@ -341,7 +341,7 @@ function spawnFfmpeg(
label: string,
stdio: StdioTuple,
): FfmpegProc {
const proc = spawn(ffmpegPath, args, { stdio });
const proc = spawn(ffmpegPath, args, { stdio, windowsHide: true });
let stderrBuf = "";
proc.stderr?.on("data", (d: Buffer) => {
stderrBuf += d.toString();
@@ -0,0 +1,60 @@
import { EventEmitter } from "node:events";
import { Readable } from "node:stream";
import { describe, expect, it, vi } from "vitest";
const { spawnMock, processFrameMock, closeSessionMock } = vi.hoisted(() => ({
spawnMock: vi.fn(),
processFrameMock: vi.fn(async () => ({ fg: Buffer.alloc(4) })),
closeSessionMock: vi.fn(async () => undefined),
}));
vi.mock("node:child_process", () => ({ spawn: spawnMock }));
vi.mock("../browser/ffmpeg.js", () => ({
findFFmpeg: () => "/fake/bin/ffmpeg",
findFFprobe: () => "/fake/bin/ffprobe",
getFFmpegInstallHint: () => "install ffmpeg",
}));
vi.mock("./inference.js", () => ({
createSession: async () => ({
provider: "test",
process: processFrameMock,
close: closeSessionMock,
}),
}));
vi.mock("@hyperframes/engine", () => ({
DEFAULT_VP9_CPU_USED: 4,
renderProvenanceArgs: () => [],
extractMediaMetadata: async () => ({ width: 1, height: 1, fps: 1, durationSeconds: 1 }),
}));
import { render } from "./pipeline.js";
function fakeFfmpeg(stdout: Readable) {
const proc = new EventEmitter() as EventEmitter & {
stdout: Readable;
stderr: EventEmitter;
stdin: EventEmitter & { write: ReturnType<typeof vi.fn>; end: ReturnType<typeof vi.fn> };
kill: ReturnType<typeof vi.fn>;
};
proc.stdout = stdout;
proc.stderr = new EventEmitter();
proc.stdin = Object.assign(new EventEmitter(), { write: vi.fn(() => true), end: vi.fn() });
proc.kill = vi.fn();
queueMicrotask(() => proc.emit("exit", 0, null));
return proc;
}
describe("background-removal FFmpeg child-process options", () => {
it("hides every FFmpeg console window", async () => {
spawnMock
.mockImplementationOnce(() => fakeFfmpeg(Readable.from([Buffer.alloc(3)])))
.mockImplementationOnce(() => fakeFfmpeg(Readable.from([])));
await render({ inputPath: "/tmp/input.mp4", outputPath: "/tmp/output.webm" });
expect(spawnMock).toHaveBeenCalledTimes(2);
for (const call of spawnMock.mock.calls) {
expect(call[2]).toEqual(expect.objectContaining({ windowsHide: true }));
}
});
});
+1 -1
View File
@@ -75,7 +75,7 @@ describe("resolveH264EncoderMode", () => {
expect(mockExecFile).toHaveBeenCalledWith(
"/custom/ffmpeg",
["-hide_banner", "-encoders"],
expect.objectContaining({ encoding: "utf-8" }),
expect.objectContaining({ encoding: "utf-8", windowsHide: true }),
);
});
});
+1
View File
@@ -28,6 +28,7 @@ export function detectH264EncoderMode(ffmpegPath: string, gpuRequested: boolean)
encoding: "utf-8",
stdio: ["ignore", "pipe", "pipe"],
timeout: 5000,
windowsHide: true,
});
return resolveH264EncoderMode(encoders, gpuRequested);
}
+19
View File
@@ -210,6 +210,25 @@ describe("findBrowser — cache resolution", () => {
expect(result).toEqual({ executablePath: HF_BINARY, source: "cache" });
});
it("hides the Windows console used by the where lookup", async () => {
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
installFsMocks({ existing: new Set() });
installPuppeteerBrowsersMock();
const execSync = vi.fn((command: string) =>
command === "where google-chrome" ? "C:\\Chrome\\chrome.exe\n" : "",
);
vi.doMock("node:child_process", () => ({ execSync, spawnSync: vi.fn() }));
const { findBrowser } = await import("./manager.js");
const result = await findBrowser();
expect(result).toEqual({ executablePath: "C:\\Chrome\\chrome.exe", source: "system" });
expect(execSync).toHaveBeenCalledWith(
"where google-chrome",
expect.objectContaining({ windowsHide: true }),
);
});
it("does not resolve to a hyperframes-cache build from an older CHROME_VERSION pin", async () => {
// A build downloaded by a prior hyperframes version (this pin has moved
// 131 -> 151 -> 152 across releases) must not satisfy resolution, or an
+1
View File
@@ -254,6 +254,7 @@ function whichBinary(name: string): string | undefined {
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
timeout: 5000,
windowsHide: true,
});
const first = output
.split(/\r?\n/)
@@ -35,6 +35,10 @@ describe("runEnvironmentChecks", () => {
expect(result.outcomes.find((outcome) => outcome.name === "FFprobe")?.ok).toBe(true);
expect(result.ffmpegPath).toBe(process.execPath);
expect(result.ffprobePath).toBe(process.execPath);
expect(execFileSync).toHaveBeenCalledTimes(2);
for (const call of execFileSync.mock.calls) {
expect(call[2]).toEqual(expect.objectContaining({ windowsHide: true }));
}
});
it("reports ffprobe as a render-blocking error when the explicit path is missing", async () => {
+5 -2
View File
@@ -61,8 +61,11 @@ type ToolVersionResult = { ok: true; detail: string } | { ok: false; detail: str
function readToolVersion(binaryPath: string): ToolVersionResult {
try {
const raw =
execFileSync(binaryPath, ["-version"], { encoding: "utf-8", timeout: 5000 }).split("\n")[0] ??
"";
execFileSync(binaryPath, ["-version"], {
encoding: "utf-8",
timeout: 5000,
windowsHide: true,
}).split("\n")[0] ?? "";
const version = parseToolVersion(raw);
return { ok: true, detail: version ? `${version} at ${binaryPath}` : binaryPath };
} catch (error) {
@@ -510,7 +510,7 @@ export async function runFfmpegOnce(
timeoutMs: number,
): Promise<FfmpegRunResult> {
return await new Promise((resolvePromise) => {
const ff = spawn(ffmpegPath, args);
const ff = spawn(ffmpegPath, args, { windowsHide: true });
let stderr = "";
let timedOut = false;
const timer = setTimeout(() => {
@@ -0,0 +1,26 @@
import { EventEmitter } from "node:events";
import { describe, expect, it, vi } from "vitest";
const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() }));
vi.mock("node:child_process", () => ({ spawn: spawnMock }));
import { runFfmpegOnce } from "./captureCompositionFrame.js";
describe("runFfmpegOnce child-process options", () => {
it("hides the FFmpeg console window", async () => {
const proc = new EventEmitter() as EventEmitter & {
stderr: EventEmitter;
kill: ReturnType<typeof vi.fn>;
};
proc.stderr = new EventEmitter();
proc.kill = vi.fn();
spawnMock.mockReturnValue(proc);
const result = runFfmpegOnce("/fake/bin/ffmpeg", ["-version"], 1000);
proc.emit("close", 0);
await result;
expect(spawnMock.mock.calls[0]?.[2]).toEqual(expect.objectContaining({ windowsHide: true }));
});
});
+3 -2
View File
@@ -50,6 +50,7 @@ export function killProcessTree(pid: number, signal: NodeJS.Signals = "SIGTERM")
execFileSync("taskkill", windowsProcessTreeKillArgs(pid), {
stdio: "ignore",
timeout: 5000,
windowsHide: true,
});
} catch {
// Process already exited or taskkill could not inspect it.
@@ -103,7 +104,7 @@ export function processIdentity(pid: number): string | null {
"-Command",
`(Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}').CreationDate.ToFileTimeUtc()`,
],
{ encoding: "utf8", timeout: 2000 },
{ encoding: "utf8", timeout: 2000, windowsHide: true },
).trim();
return created ? `windows:${created}` : null;
}
@@ -142,7 +143,7 @@ function processParentPid(pid: number): number | null {
"-Command",
`(Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}').ParentProcessId`,
],
{ encoding: "utf8", timeout: 2000 },
{ encoding: "utf8", timeout: 2000, windowsHide: true },
)
: execFileSync("ps", ["-o", "ppid=", "-p", String(pid)], {
encoding: "utf8",
@@ -0,0 +1,42 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { execFileSyncMock } = vi.hoisted(() => ({ execFileSyncMock: vi.fn() }));
vi.mock("node:child_process", () => ({
execFileSync: execFileSyncMock,
execSync: vi.fn(),
}));
import { isProcessDescendant, killProcessTree, processIdentity } from "./orphanCleanup.js";
describe("Windows orphan-cleanup child-process options", () => {
const originalPlatform = process.platform;
beforeEach(() => {
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
execFileSyncMock.mockImplementation((command: string, args: string[]) => {
if (command === "taskkill") return Buffer.alloc(0);
const script = args.at(-1) ?? "";
if (script.includes("CreationDate")) return "123456\n";
if (script.includes("ProcessId = 400")) return "300\n";
if (script.includes("ProcessId = 300")) return "200\n";
return "1\n";
});
});
afterEach(() => {
Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true });
vi.clearAllMocks();
});
it("hides taskkill and PowerShell console windows", () => {
killProcessTree(4321);
expect(processIdentity(4321)).toBe("windows:123456");
expect(isProcessDescendant(400, 200)).toBe(true);
expect(execFileSyncMock).toHaveBeenCalledTimes(4);
for (const call of execFileSyncMock.mock.calls) {
expect(call[2]).toEqual(expect.objectContaining({ windowsHide: true }));
}
});
});
+24 -3
View File
@@ -86,13 +86,14 @@ describe("sampledAlphaIsFullyOpaque", () => {
}
async function importWithMocks(
execImpl: (cmd: string, args: readonly string[], opts?: unknown) => Buffer,
execImpl: (cmd: string, args: readonly string[], opts?: unknown) => string | Buffer,
ffmpegPath: string | null = FAKE_FFMPEG,
ffprobePath: string | null = null,
) {
vi.doMock("node:child_process", () => ({ execFileSync: execImpl }));
vi.doMock("../browser/ffmpeg.js", () => ({
findFFmpeg: () => ffmpegPath,
findFFprobe: () => null,
findFFprobe: () => ffprobePath,
}));
return await import("./webmAlphaCheck.js");
}
@@ -165,8 +166,10 @@ describe("sampledAlphaIsFullyOpaque", () => {
// Without `-c:v libvpx-vp9` BEFORE `-i`, the check would falsely report
// alpha=255 on WebMs whose alpha is genuinely present.
let capturedArgs: readonly string[] | undefined;
const { sampledAlphaIsFullyOpaque } = await importWithMocks((_cmd, args) => {
let capturedOptions: unknown;
const { sampledAlphaIsFullyOpaque } = await importWithMocks((_cmd, args, options) => {
capturedArgs = args;
capturedOptions = options;
return opaqueBuffer(3);
});
sampledAlphaIsFullyOpaque(FAKE_WEBM);
@@ -182,5 +185,23 @@ describe("sampledAlphaIsFullyOpaque", () => {
expect(argList).toContain("-frames:v");
expect(argList[argList.indexOf("-frames:v") + 1]).toBe("3");
expect(argList).toContain("rawvideo");
expect(capturedOptions).toEqual(expect.objectContaining({ windowsHide: true }));
});
it("hides the ffprobe console window", async () => {
let capturedOptions: unknown;
const { warnIfWebmAlphaDropped } = await importWithMocks(
(_cmd, _args, options) => {
capturedOptions = options;
return JSON.stringify({ streams: [{ codec_name: "vp9", tags: {} }] });
},
null,
"/fake/bin/ffprobe",
);
vi.spyOn(console, "warn").mockImplementation(() => {});
warnIfWebmAlphaDropped(FAKE_WEBM, "webm", false);
expect(capturedOptions).toEqual(expect.objectContaining({ windowsHide: true }));
});
});
+7 -2
View File
@@ -89,7 +89,7 @@ function probeWebmAlpha(filePath: string): WebmAlphaProbe {
"--",
filePath,
],
{ encoding: "utf-8", timeout: 15_000 },
{ encoding: "utf-8", timeout: 15_000, windowsHide: true },
);
const parsed = JSON.parse(raw) as {
streams?: Array<{ codec_name?: string; tags?: Record<string, string> }>;
@@ -159,7 +159,12 @@ export function sampledAlphaIsFullyOpaque(filePath: string): boolean | undefined
"rawvideo",
"-",
],
{ timeout: 30_000, maxBuffer: 4096, stdio: ["ignore", "pipe", "pipe"] },
{
timeout: 30_000,
maxBuffer: 4096,
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
},
);
if (
buf.length === 0 ||
+1 -1
View File
@@ -27,7 +27,7 @@ const PROBE_CONCURRENCY = 8;
function execFileAsync(file: string, args: string[]): Promise<string> {
return new Promise((resolvePromise, reject) => {
execFile(file, args, { timeout: PROBE_TIMEOUT_MS }, (error, stdout) => {
execFile(file, args, { timeout: PROBE_TIMEOUT_MS, windowsHide: true }, (error, stdout) => {
if (error) reject(error);
else resolvePromise(stdout.toString());
});
@@ -0,0 +1,32 @@
import { describe, expect, it, vi } from "vitest";
const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() }));
vi.mock("node:child_process", () => {
const mocked = { execFile: execFileMock };
return { ...mocked, default: mocked };
});
vi.mock("@hyperframes/parsers/ff-binaries", () => ({
findFfBinary: () => "/fake/bin/ffprobe",
}));
import { lintHevcPreviewCodec } from "./hevcPreviewLint.js";
describe("HEVC preview probe options", () => {
it("hides the ffprobe console window", async () => {
execFileMock.mockImplementation(
(
_command: string,
_args: string[],
_options: unknown,
callback: (...args: unknown[]) => void,
) => {
callback(null, JSON.stringify({ streams: [{ codec_name: "hevc" }] }));
},
);
await lintHevcPreviewCodec(new Map([["/tmp/clip.mp4", "clip.mp4"]]));
expect(execFileMock.mock.calls[0]?.[2]).toEqual(expect.objectContaining({ windowsHide: true }));
});
});
@@ -0,0 +1,54 @@
import { EventEmitter } from "node:events";
import { describe, expect, it, vi } from "vitest";
const { execFileMock, spawnMock } = vi.hoisted(() => ({
execFileMock: vi.fn(),
spawnMock: vi.fn(),
}));
vi.mock("node:child_process", () => {
const mocked = { execFile: execFileMock, spawn: spawnMock };
return { ...mocked, default: mocked };
});
vi.mock("@hyperframes/parsers/ff-binaries", () => ({
findFfBinary: (name: string) => `/fake/bin/${name}`,
}));
import { probeMediaMetadata } from "./mediaMetadata.js";
import { decodeAudioPeaks } from "./waveform.js";
describe("Studio child-process options", () => {
it("hides the ffprobe console window", async () => {
execFileMock.mockImplementation(
(
_command: string,
_args: string[],
_options: unknown,
callback: (...args: unknown[]) => void,
) => {
callback(
null,
JSON.stringify({ streams: [{ codec_type: "video", codec_name: "h264" }] }),
"",
);
},
);
await probeMediaMetadata("/tmp/clip.mp4");
expect(execFileMock.mock.calls[0]?.[2]).toEqual(expect.objectContaining({ windowsHide: true }));
});
it("hides the waveform FFmpeg console window", async () => {
const proc = new EventEmitter() as EventEmitter & { stdout: EventEmitter };
proc.stdout = new EventEmitter();
spawnMock.mockReturnValue(proc);
const peaks = decodeAudioPeaks("/tmp/audio.wav");
proc.stdout.emit("data", Buffer.from(new Float32Array([0.5]).buffer));
proc.emit("close", 0);
await peaks;
expect(spawnMock.mock.calls[0]?.[2]).toEqual(expect.objectContaining({ windowsHide: true }));
});
});
@@ -28,7 +28,7 @@ const execFileRunner: FfprobeRunner = (command, args, options) =>
execFile(
command,
args,
{ timeout: options?.timeout, maxBuffer: options?.maxBuffer },
{ timeout: options?.timeout, maxBuffer: options?.maxBuffer, windowsHide: true },
(error, stdout, stderr) => {
if (error && error.code === "ENOENT") {
resolvePromise({ status: null, stdout: "", stderr: "", error });
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { validateUploadedMedia, validateUploadedMediaBuffer } from "./mediaValidation.js";
describe("validateUploadedMedia", () => {
@@ -11,13 +11,18 @@ describe("validateUploadedMedia", () => {
});
it("accepts video files with a video stream", () => {
expect(
validateUploadedMedia("/tmp/test.mp4", () => ({
status: 0,
stdout: JSON.stringify({ streams: [{ codec_type: "video" }] }),
stderr: "",
})),
).toEqual({ ok: true });
const runner = vi.fn(() => ({
status: 0,
stdout: JSON.stringify({ streams: [{ codec_type: "video" }] }),
stderr: "",
}));
expect(validateUploadedMedia("/tmp/test.mp4", runner)).toEqual({ ok: true });
expect(runner).toHaveBeenCalledWith(
"ffprobe",
expect.any(Array),
expect.objectContaining({ windowsHide: true }),
);
});
it("rejects video files with no supported video stream", () => {
@@ -9,6 +9,7 @@ const AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac)$/i;
type FfprobeRunner = (
command: string,
args: string[],
options: { windowsHide: boolean },
) => {
status: number | null;
stdout: string | Buffer;
@@ -26,16 +27,11 @@ export function validateUploadedMedia(
return { ok: true };
}
const result = runner("ffprobe", [
"-v",
"error",
"-show_entries",
"stream=codec_type",
"-of",
"json",
"--",
filePath,
]);
const result = runner(
"ffprobe",
["-v", "error", "-show_entries", "stream=codec_type", "-of", "json", "--", filePath],
{ windowsHide: true },
);
if (result.error?.code === "ENOENT") {
return { ok: true };
@@ -28,14 +28,15 @@ function createFakeProc(): FakeProc {
return proc;
}
type SpawnCall = { command: string; args: string[]; proc: FakeProc };
type SpawnImpl = (command: string, args: string[]) => FakeProc;
type SpawnOptions = { windowsHide?: boolean };
type SpawnCall = { command: string; args: string[]; options?: SpawnOptions; proc: FakeProc };
type SpawnImpl = (command: string, args: string[], options?: SpawnOptions) => FakeProc;
function createSpawnSpy(): { spawn: SpawnImpl; calls: SpawnCall[] } {
const calls: SpawnCall[] = [];
const spawn: SpawnImpl = (command, args) => {
const spawn: SpawnImpl = (command, args, options) => {
const proc = createFakeProc();
calls.push({ command, args, proc });
calls.push({ command, args, options, proc });
return proc;
};
return { spawn, calls };
@@ -222,6 +223,7 @@ describe("resolveProxy", () => {
await flush();
expect(calls[0]!.args).toEqual(["-hide_banner", "-filters"]);
expect(calls[0]!.options?.windowsHide).toBe(true);
calls[0]!.proc.stdout.emit(
"data",
Buffer.from(" ..C zscale V->V zimg scale\n T.C tonemap V->V tone map\n"),
@@ -459,6 +461,7 @@ describe("resolveProxy", () => {
const retry = resolveProxy(projectDir, sourcePath);
await flush();
expect(calls).toHaveLength(2);
expect(calls[1]!.options?.windowsHide).toBe(true);
succeed(calls[1]!);
await expect(retry).resolves.toBeTruthy();
});
@@ -289,6 +289,7 @@ function ensureHdrFilters(ffmpegPath: string): Promise<void> {
const promise = new Promise<void>((resolveCheck, rejectCheck) => {
const proc = spawn(ffmpegPath, ["-hide_banner", "-filters"], {
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
let stdout = "";
proc.stdout?.on("data", (chunk: Buffer) => {
@@ -411,6 +412,7 @@ async function runFfmpeg(
stdio: ["ignore", "ignore", "pipe"],
timeout: TRANSCODE_TIMEOUT_MS,
killSignal: "SIGKILL",
windowsHide: true,
});
let stderrTail = "";
proc.stderr?.on("data", (chunk: Buffer) => {
@@ -67,7 +67,7 @@ export function decodeAudioPeaks(audioPath: string): Promise<number[]> {
"-vn",
"pipe:1",
],
{ stdio: ["ignore", "pipe", "ignore"] },
{ stdio: ["ignore", "pipe", "ignore"], windowsHide: true },
);
const chunks: Buffer[] = [];