From f7d40b4553c7605c6ad0071258e9aa2125fab03a Mon Sep 17 00:00:00 2001 From: miga-heygen Date: Wed, 16 Sep 2026 12:14:42 +0000 Subject: [PATCH 1/7] fix(cli): hide console windows on Windows for background/detached spawns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add windowsHide: true to 8 spawn/execFile call sites that were missing it: ffprobe/ffmpeg probes in the producer and engine packages, the telemetry exit-flush and studio dev-server spawns, and the browser-open and background-preview detached launches in the CLI. previewLifecycle.ts's spawnDetachedPreview redirects stdout/stderr to a raw log-file descriptor; libuv only adds Windows' CREATE_NO_WINDOW flag when no stdio slot inherits a raw handle, so windowsHide alone does not fully suppress a console flash there (documented inline) — it's still added for consistency and because it correctly sets the process's show-window state regardless. Co-Authored-By: Miguel Ángel --- .../init.transcodeToMp4.windowsHide.test.ts | 26 ++++++++++++ packages/cli/src/commands/init.ts | 4 +- packages/cli/src/commands/preview.ts | 2 + .../cli/src/commands/previewLifecycle.test.ts | 7 +++- packages/cli/src/commands/previewLifecycle.ts | 7 ++++ .../telemetry/transport.windowsHide.test.ts | 19 +++++++++ packages/cli/src/utils/openBrowser.ts | 1 + .../src/utils/openBrowser.windowsHide.test.ts | 21 ++++++++++ .../src/utils/psnrFilterAvailability.test.ts | 21 ++++++++-- .../render/audioPadTrim.windowsHide.test.ts | 40 +++++++++++++++++++ 10 files changed, 141 insertions(+), 7 deletions(-) create mode 100644 packages/cli/src/commands/init.transcodeToMp4.windowsHide.test.ts create mode 100644 packages/cli/src/telemetry/transport.windowsHide.test.ts create mode 100644 packages/cli/src/utils/openBrowser.windowsHide.test.ts create mode 100644 packages/producer/src/services/render/audioPadTrim.windowsHide.test.ts diff --git a/packages/cli/src/commands/init.transcodeToMp4.windowsHide.test.ts b/packages/cli/src/commands/init.transcodeToMp4.windowsHide.test.ts new file mode 100644 index 00000000000..05c11c3229a --- /dev/null +++ b/packages/cli/src/commands/init.transcodeToMp4.windowsHide.test.ts @@ -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 })); +vi.mock("../browser/ffmpeg.js", () => ({ + findFFmpeg: () => "/fake/bin/ffmpeg", + findFFprobe: () => "/fake/bin/ffprobe", + getFFmpegInstallHint: () => "install ffmpeg", +})); + +import { transcodeToMp4 } from "./init.js"; + +describe("transcodeToMp4 child-process options", () => { + it("hides the ffmpeg console window on Windows", async () => { + const proc = new EventEmitter(); + spawnMock.mockReturnValue(proc); + + const result = transcodeToMp4("/fake/in.webm", "/fake/out.mp4"); + proc.emit("close", 0); + await result; + + expect(spawnMock.mock.calls[0]?.[2]).toEqual(expect.objectContaining({ windowsHide: true })); + }); +}); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 22b82baa7df..9fd043e3bd0 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -185,7 +185,7 @@ function isWebCompatible(codec: string): boolean { // hasFFmpeg is imported from whisper/manager.ts to avoid duplication -function transcodeToMp4(inputPath: string, outputPath: string): Promise { +export function transcodeToMp4(inputPath: string, outputPath: string): Promise { return new Promise((resolvePromise) => { const ffmpegPath = findFFmpeg(); if (!ffmpegPath) { @@ -210,7 +210,7 @@ function transcodeToMp4(inputPath: string, outputPath: string): Promise "-y", outputPath, ], - { stdio: "pipe" }, + { stdio: "pipe", windowsHide: true }, ); child.on("close", (code) => resolvePromise(code === 0)); diff --git a/packages/cli/src/commands/preview.ts b/packages/cli/src/commands/preview.ts index c453dec6808..72ecc3fe9fe 100644 --- a/packages/cli/src/commands/preview.ts +++ b/packages/cli/src/commands/preview.ts @@ -1442,6 +1442,7 @@ async function runDevMode(dir: string, options?: StudioLaunchOptions): Promise { const replacement = { ...server, port: 3211, pid: "5432" }; let scans = 0; const scan = vi.fn(async () => (++scans < 3 ? [server] : [server, replacement])); - const spawn = vi.fn(() => ({ pid: 5432, unref: vi.fn() })); + const spawn = vi.fn((_command: string, _args: string[], _options: unknown) => ({ + pid: 5432, + unref: vi.fn(), + })); const result = await startBackgroundPreview(projectDir, 3002, { forceNew: true, @@ -171,6 +174,8 @@ describe("background preview lifecycle", () => { expect(result).toMatchObject({ type: "started", port: 3211, pid: 5432 }); expect(spawn).toHaveBeenCalledOnce(); + // windowsHide keeps the detached child from flashing a console on Windows. + expect(spawn.mock.calls[0]?.[2]).toEqual(expect.objectContaining({ windowsHide: true })); }); it.each([ diff --git a/packages/cli/src/commands/previewLifecycle.ts b/packages/cli/src/commands/previewLifecycle.ts index 57bb8b311f5..ffcff5987f9 100644 --- a/packages/cli/src/commands/previewLifecycle.ts +++ b/packages/cli/src/commands/previewLifecycle.ts @@ -34,6 +34,7 @@ type SpawnPreview = ( detached: boolean; stdio: ["ignore", number, number]; env: NodeJS.ProcessEnv; + windowsHide: boolean; }, ) => SpawnResult; @@ -211,8 +212,14 @@ function spawnDetachedPreview( buildBackgroundPreviewArgs(dependencies.argv ?? process.argv.slice(1)), { detached: true, + // libuv only adds Windows' CREATE_NO_WINDOW when no stdio slot + // inherits a raw fd, and logFd is one — so this does less here than + // on the codebase's "ignore"/"pipe" spawns. Harmless and kept for + // consistency; suppressing the flash outright needs the child to + // open its own log file. stdio: ["ignore", logFd, logFd], env: process.env, + windowsHide: true, }, ); } finally { diff --git a/packages/cli/src/telemetry/transport.windowsHide.test.ts b/packages/cli/src/telemetry/transport.windowsHide.test.ts new file mode 100644 index 00000000000..fde5954e2ad --- /dev/null +++ b/packages/cli/src/telemetry/transport.windowsHide.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it, vi } from "vitest"; + +const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() })); + +vi.mock("node:child_process", () => ({ spawn: spawnMock })); + +import { enqueue, flushSync } from "./transport.js"; + +describe("flushSync child-process options", () => { + it("hides the flush-on-exit console window on Windows", () => { + spawnMock.mockReturnValue({ unref: vi.fn() }); + enqueue("test_event", {}); + + flushSync(); + + expect(spawnMock).toHaveBeenCalledOnce(); + expect(spawnMock.mock.calls[0]?.[2]).toEqual(expect.objectContaining({ windowsHide: true })); + }); +}); diff --git a/packages/cli/src/utils/openBrowser.ts b/packages/cli/src/utils/openBrowser.ts index 0cde28af9d9..5fd0f4316d8 100644 --- a/packages/cli/src/utils/openBrowser.ts +++ b/packages/cli/src/utils/openBrowser.ts @@ -81,6 +81,7 @@ export function openBrowser(url: string, options: OpenBrowserOptions = {}): void const child = spawn(options.browserPath, args, { detached: true, stdio: "ignore", + windowsHide: true, }); child.on("error", () => {}); child.unref(); diff --git a/packages/cli/src/utils/openBrowser.windowsHide.test.ts b/packages/cli/src/utils/openBrowser.windowsHide.test.ts new file mode 100644 index 00000000000..84adaee8db3 --- /dev/null +++ b/packages/cli/src/utils/openBrowser.windowsHide.test.ts @@ -0,0 +1,21 @@ +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 { openBrowser } from "./openBrowser.js"; + +describe("openBrowser child-process options", () => { + it("hides the spawned browser's console window on Windows", () => { + // `browserPath` takes the direct-spawn path, so the `open` package is + // never imported here. + const proc = Object.assign(new EventEmitter(), { unref: vi.fn() }); + spawnMock.mockReturnValue(proc); + + openBrowser("http://localhost:3002", { browserPath: "/usr/bin/chromium" }); + + expect(spawnMock.mock.calls[0]?.[2]).toEqual(expect.objectContaining({ windowsHide: true })); + }); +}); diff --git a/packages/engine/src/utils/psnrFilterAvailability.test.ts b/packages/engine/src/utils/psnrFilterAvailability.test.ts index d8a45cdcec7..f14bd603269 100644 --- a/packages/engine/src/utils/psnrFilterAvailability.test.ts +++ b/packages/engine/src/utils/psnrFilterAvailability.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; interface ExecFileCall { file: string; args: readonly string[]; + options: unknown; } type ExecFileOutcome = @@ -31,8 +32,9 @@ function createExecFileSpy(outcome: ExecFileOutcome): { async function run( file: string, args: readonly string[], + options: unknown, ): Promise<{ stdout: string; stderr: string }> { - calls.push({ file, args }); + calls.push({ file, args, options }); if (outcome.kind === "enoent") { const err = new Error("spawn ffmpeg ENOENT") as NodeJS.ErrnoException; err.code = "ENOENT"; @@ -55,10 +57,10 @@ function createExecFileSpy(outcome: ExecFileOutcome): { const execFile = (( file: string, args: readonly string[], - _options: unknown, + options: unknown, callback: (err: Error | null, stdout?: string, stderr?: string) => void, ) => { - run(file, args).then( + run(file, args, options).then( ({ stdout, stderr }) => process.nextTick(() => callback(null, stdout, stderr)), (err: Error) => process.nextTick(() => callback(err)), ); @@ -71,7 +73,8 @@ function createExecFileSpy(outcome: ExecFileOutcome): { (execFile as { [k: symbol]: unknown })[promisify.custom] = ( file: string, args: readonly string[], - ) => run(file, args); + options: unknown, + ) => run(file, args, options); return { execFile, calls }; } @@ -175,6 +178,16 @@ describe("isPsnrFilterAvailable", () => { expect(calls.length).toBe(2); }); + it("probes ffmpeg with windowsHide so no console window flashes on Windows", async () => { + const { execFile, calls } = createExecFileSpy({ kind: "ok", stdout: "psnr" }); + vi.doMock("node:child_process", () => ({ execFile })); + + const { isPsnrFilterAvailable } = await import("./psnrFilterAvailability.js"); + await isPsnrFilterAvailable(); + + expect(calls[0]?.options).toEqual(expect.objectContaining({ windowsHide: true })); + }); + it("does not treat a whole-string 'psnr' inside another word as the filter", async () => { const { execFile } = createExecFileSpy({ kind: "ok", diff --git a/packages/producer/src/services/render/audioPadTrim.windowsHide.test.ts b/packages/producer/src/services/render/audioPadTrim.windowsHide.test.ts new file mode 100644 index 00000000000..9c2c2543bf6 --- /dev/null +++ b/packages/producer/src/services/render/audioPadTrim.windowsHide.test.ts @@ -0,0 +1,40 @@ +import { EventEmitter } from "node:events"; +import { describe, expect, it, mock } from "bun:test"; + +const spawnMock = mock(() => { + const proc = new EventEmitter() as EventEmitter & { + stdout: EventEmitter; + stderr: EventEmitter; + kill: () => boolean; + }; + proc.stdout = new EventEmitter(); + proc.stderr = new EventEmitter(); + proc.kill = () => true; + queueMicrotask(() => { + proc.emit("spawn"); + proc.stdout.emit("data", Buffer.from('{"streams":[]}')); + proc.emit("close", 0, null); + }); + return proc; +}); + +const realChildProcess = await import("node:child_process"); +mock.module("node:child_process", () => ({ ...realChildProcess, spawn: spawnMock })); + +const { padOrTrimAudioToVideoFrameCount } = await import("./audioPadTrim.js"); + +describe("runFfprobeJson child-process options", () => { + it("hides the ffprobe console window when probing the video", async () => { + // The audio probe is stubbed out, so every spawn here comes from the + // real video probe running through runFfprobeJson. + await padOrTrimAudioToVideoFrameCount({ + videoPath: "/fake/video.mp4", + audioPath: "/fake/audio.m4a", + outputPath: "/fake/out.m4a", + probeAudioInfo: async () => ({ durationSeconds: 1 }), + }).catch(() => undefined); + + expect(spawnMock).toHaveBeenCalled(); + expect(spawnMock.mock.calls[0]?.[2]).toEqual(expect.objectContaining({ windowsHide: true })); + }); +}); From 4d0bc55df8d54420758fb63b12d41ab2188f8298 Mon Sep 17 00:00:00 2001 From: miga-heygen Date: Wed, 16 Sep 2026 13:02:02 +0000 Subject: [PATCH 2/7] fix(cli): correct windowsHide flash-risk claims and add missing dev-server spawn tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The previewLifecycle.ts comment (and its mirrored test comment) overstated the residual console-flash risk on the detached background preview spawn: DETACHED_PROCESS already leaves that child without a console regardless of whether CREATE_NO_WINDOW gets applied. - Add the two missing regression tests for preview.ts's runDevMode and runLocalStudioMode spawns, exporting both for test access. - Drop an unused export in audioPadTrim.ts flagged by the repo's dead-code gate; the constant is only ever used within its own file. Co-Authored-By: Miguel Ángel --- packages/cli/src/commands/preview.test.ts | 65 ++++++++++++++++++- packages/cli/src/commands/preview.ts | 7 +- .../cli/src/commands/previewLifecycle.test.ts | 2 +- packages/cli/src/commands/previewLifecycle.ts | 8 +-- .../src/services/render/audioPadTrim.ts | 2 +- 5 files changed, 74 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/commands/preview.test.ts b/packages/cli/src/commands/preview.test.ts index 862465622cc..66c2fb74dec 100644 --- a/packages/cli/src/commands/preview.test.ts +++ b/packages/cli/src/commands/preview.test.ts @@ -1,6 +1,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import * as clack from "@clack/prompts"; import { afterEach, describe, expect, it, vi } from "vitest"; import { runCommand } from "citty"; @@ -514,3 +515,65 @@ describe("waitForStudioChildClose", () => { expect(signalTarget.off).toHaveBeenCalledTimes(2); }); }); + +describe("studio dev-server spawns", () => { + function fakeStudioChild() { + return { + pid: 4321, + exitCode: 0, + signalCode: null, + stdout: { on: vi.fn(), removeListener: vi.fn() }, + stderr: { on: vi.fn(), removeListener: vi.fn() }, + on: vi.fn(), + once: vi.fn(), + }; + } + + // Returns the spawn spy. mkdirSync/existsSync are stubbed too, so + // linkProjectIntoStudioData never touches the real studio data directory. + function mockStudioSpawn() { + const spawn = vi.fn((_command: string, _args: string[], _options: unknown) => + fakeStudioChild(), + ); + vi.doMock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, spawn }; + }); + vi.doMock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, mkdirSync: () => undefined, existsSync: () => true }; + }); + vi.resetModules(); + return spawn; + } + + afterEach(() => { + vi.doUnmock("node:child_process"); + vi.doUnmock("node:fs"); + vi.resetModules(); + }); + + it("runDevMode passes windowsHide to the studio dev-server spawn", async () => { + const spawn = mockStudioSpawn(); + + const { runDevMode } = await import("./preview.js"); + await runDevMode("/tmp/hf-preview-devmode-test", { json: true }); + + expect(spawn).toHaveBeenCalledOnce(); + expect(spawn.mock.calls[0]?.[2]).toMatchObject({ windowsHide: true }); + }); + + it("runLocalStudioMode passes windowsHide to the local Vite spawn", async () => { + const spawn = mockStudioSpawn(); + + // @hyperframes/studio is resolved for real, so the project dir has to sit + // inside the monorepo's node_modules tree. + const thisFile = fileURLToPath(import.meta.url); + const localStudioProjectDir = resolve(dirname(thisFile), "..", ".."); + const { runLocalStudioMode } = await import("./preview.js"); + await runLocalStudioMode(localStudioProjectDir, { json: true }); + + expect(spawn).toHaveBeenCalledOnce(); + expect(spawn.mock.calls[0]?.[2]).toMatchObject({ windowsHide: true }); + }); +}); diff --git a/packages/cli/src/commands/preview.ts b/packages/cli/src/commands/preview.ts index 72ecc3fe9fe..4322ce349dc 100644 --- a/packages/cli/src/commands/preview.ts +++ b/packages/cli/src/commands/preview.ts @@ -1422,7 +1422,7 @@ export function reportPreviewShutdown(json: boolean): void { /** * Dev mode: spawn the studio dev server from the monorepo. */ -async function runDevMode(dir: string, options?: StudioLaunchOptions): Promise { +export async function runDevMode(dir: string, options?: StudioLaunchOptions): Promise { // Find monorepo root by navigating from packages/cli/src/commands/ const thisFile = fileURLToPath(import.meta.url); const repoRoot = resolve(dirname(thisFile), "..", "..", "..", ".."); @@ -1488,7 +1488,10 @@ function hasLocalStudio(dir: string): boolean { * Local studio mode: spawn Vite using a locally installed @hyperframes/studio. * Provides full Vite HMR and the complete studio experience. */ -async function runLocalStudioMode(dir: string, options?: StudioLaunchOptions): Promise { +export async function runLocalStudioMode( + dir: string, + options?: StudioLaunchOptions, +): Promise { const req = createRequire(join(dir, "package.json")); const studioPkgPath = dirname(req.resolve("@hyperframes/studio/package.json")); const pName = options?.projectName ?? basename(dir); diff --git a/packages/cli/src/commands/previewLifecycle.test.ts b/packages/cli/src/commands/previewLifecycle.test.ts index 223e4b730ba..a4f291d10f9 100644 --- a/packages/cli/src/commands/previewLifecycle.test.ts +++ b/packages/cli/src/commands/previewLifecycle.test.ts @@ -174,7 +174,7 @@ describe("background preview lifecycle", () => { expect(result).toMatchObject({ type: "started", port: 3211, pid: 5432 }); expect(spawn).toHaveBeenCalledOnce(); - // windowsHide keeps the detached child from flashing a console on Windows. + // DETACHED_PROCESS is what hides this child; the flag is set for consistency. expect(spawn.mock.calls[0]?.[2]).toEqual(expect.objectContaining({ windowsHide: true })); }); diff --git a/packages/cli/src/commands/previewLifecycle.ts b/packages/cli/src/commands/previewLifecycle.ts index ffcff5987f9..c40251b59d3 100644 --- a/packages/cli/src/commands/previewLifecycle.ts +++ b/packages/cli/src/commands/previewLifecycle.ts @@ -212,11 +212,9 @@ function spawnDetachedPreview( buildBackgroundPreviewArgs(dependencies.argv ?? process.argv.slice(1)), { detached: true, - // libuv only adds Windows' CREATE_NO_WINDOW when no stdio slot - // inherits a raw fd, and logFd is one — so this does less here than - // on the codebase's "ignore"/"pipe" spawns. Harmless and kept for - // consistency; suppressing the flash outright needs the child to - // open its own log file. + // windowsHide's CREATE_NO_WINDOW doesn't reach a child whose stdio + // inherits a raw fd (logFd), but DETACHED_PROCESS already leaves this + // one without a console; the flag is kept for consistency. stdio: ["ignore", logFd, logFd], env: process.env, windowsHide: true, diff --git a/packages/producer/src/services/render/audioPadTrim.ts b/packages/producer/src/services/render/audioPadTrim.ts index 9b834b922b7..61c6117de99 100644 --- a/packages/producer/src/services/render/audioPadTrim.ts +++ b/packages/producer/src/services/render/audioPadTrim.ts @@ -41,7 +41,7 @@ import { redactKnownPaths, redactTelemetryString } from "@hyperframes/core"; const AUDIO_DURATION_TOLERANCE_SECONDS = 0.001; /** Delivery headroom applied after every AAC encode in this stage. */ -export const AAC_DELIVERY_TRUE_PEAK_DBFS = -1; +const AAC_DELIVERY_TRUE_PEAK_DBFS = -1; const AAC_TRUE_PEAK_CORRECTION_HEADROOM_DB = 0.5; const MAX_TRUE_PEAK_CORRECTION_PASSES = 3; From 502feb7172cba686e8bf95ab070661025e9651e4 Mon Sep 17 00:00:00 2001 From: miga-heygen Date: Wed, 16 Sep 2026 13:32:01 +0000 Subject: [PATCH 3/7] fix(engine): hide console window for the drawElement self-verify PSNR comparison MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit psnr.ts's execFile call runs in-process inside the Studio server's render path (parallelCoordinator.ts's drawElement disk self-verify), so a render started from Studio's UI under a detached preview hits this call from a console-less parent — the same flash case the other windowsHide sites in this PR already cover. psnrFilterAvailability.ts's probe is on the same in-process render path and already had the flag. Co-Authored-By: Miguel Ángel --- .../engine/src/utils/psnr.windowsHide.test.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 packages/engine/src/utils/psnr.windowsHide.test.ts diff --git a/packages/engine/src/utils/psnr.windowsHide.test.ts b/packages/engine/src/utils/psnr.windowsHide.test.ts new file mode 100644 index 00000000000..a750938f33c --- /dev/null +++ b/packages/engine/src/utils/psnr.windowsHide.test.ts @@ -0,0 +1,32 @@ +import { promisify } from "node:util"; +import { describe, expect, it, vi } from "vitest"; + +describe("psnrDb", () => { + it("runs ffmpeg with windowsHide so no console window flashes on Windows", async () => { + const run = vi.fn(async (_file: string, _args: readonly string[], _options: unknown) => ({ + stdout: "", + stderr: "average:30.5", + })); + // psnr.ts only ever calls `promisify(execFile)`, so the custom impl is the + // whole contract: promisified without it, the mock would look like a + // single-result callback and `{stderr}` would destructure to undefined. + const execFile = Object.assign( + () => { + throw new Error("psnr.ts must call execFile through promisify"); + }, + { [promisify.custom]: run }, + ); + vi.resetModules(); + vi.doMock("node:child_process", () => ({ execFile })); + + const { psnrDb } = await import("./psnr.js"); + await psnrDb(Buffer.from("a"), Buffer.from("b")); + + expect(run).toHaveBeenCalledOnce(); + expect(run).toHaveBeenCalledWith( + expect.any(String), + expect.any(Array), + expect.objectContaining({ windowsHide: true }), + ); + }); +}); From 3104833ce63a0515d10c68217ac2b05072bfce8e Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 2 Oct 2026 07:01:09 -0700 Subject: [PATCH 4/7] fix(cli): keep only the spawn sites main still misses and leave browser windows visible windowsHide maps to libuv UV_PROCESS_WINDOWS_HIDE, which also starts GUI apps with SW_HIDE, so the detached browser launch must not set it. The background preview child already has no console (DETACHED_PROCESS with inherited log fds), so its flag was a no-op. The telemetry, psnr and audioPadTrim sites shipped on main via #3839 and #4604. --- .../cli/src/commands/previewLifecycle.test.ts | 7 +--- packages/cli/src/commands/previewLifecycle.ts | 5 --- .../telemetry/transport.windowsHide.test.ts | 19 --------- packages/cli/src/utils/openBrowser.ts | 1 - .../src/utils/openBrowser.windowsHide.test.ts | 21 ---------- .../engine/src/utils/psnr.windowsHide.test.ts | 32 --------------- .../src/utils/psnrFilterAvailability.test.ts | 21 ++-------- .../src/services/render/audioPadTrim.ts | 2 +- .../render/audioPadTrim.windowsHide.test.ts | 40 ------------------- 9 files changed, 6 insertions(+), 142 deletions(-) delete mode 100644 packages/cli/src/telemetry/transport.windowsHide.test.ts delete mode 100644 packages/cli/src/utils/openBrowser.windowsHide.test.ts delete mode 100644 packages/engine/src/utils/psnr.windowsHide.test.ts delete mode 100644 packages/producer/src/services/render/audioPadTrim.windowsHide.test.ts diff --git a/packages/cli/src/commands/previewLifecycle.test.ts b/packages/cli/src/commands/previewLifecycle.test.ts index a4f291d10f9..fe737754e7e 100644 --- a/packages/cli/src/commands/previewLifecycle.test.ts +++ b/packages/cli/src/commands/previewLifecycle.test.ts @@ -159,10 +159,7 @@ describe("background preview lifecycle", () => { const replacement = { ...server, port: 3211, pid: "5432" }; let scans = 0; const scan = vi.fn(async () => (++scans < 3 ? [server] : [server, replacement])); - const spawn = vi.fn((_command: string, _args: string[], _options: unknown) => ({ - pid: 5432, - unref: vi.fn(), - })); + const spawn = vi.fn(() => ({ pid: 5432, unref: vi.fn() })); const result = await startBackgroundPreview(projectDir, 3002, { forceNew: true, @@ -174,8 +171,6 @@ describe("background preview lifecycle", () => { expect(result).toMatchObject({ type: "started", port: 3211, pid: 5432 }); expect(spawn).toHaveBeenCalledOnce(); - // DETACHED_PROCESS is what hides this child; the flag is set for consistency. - expect(spawn.mock.calls[0]?.[2]).toEqual(expect.objectContaining({ windowsHide: true })); }); it.each([ diff --git a/packages/cli/src/commands/previewLifecycle.ts b/packages/cli/src/commands/previewLifecycle.ts index c40251b59d3..57bb8b311f5 100644 --- a/packages/cli/src/commands/previewLifecycle.ts +++ b/packages/cli/src/commands/previewLifecycle.ts @@ -34,7 +34,6 @@ type SpawnPreview = ( detached: boolean; stdio: ["ignore", number, number]; env: NodeJS.ProcessEnv; - windowsHide: boolean; }, ) => SpawnResult; @@ -212,12 +211,8 @@ function spawnDetachedPreview( buildBackgroundPreviewArgs(dependencies.argv ?? process.argv.slice(1)), { detached: true, - // windowsHide's CREATE_NO_WINDOW doesn't reach a child whose stdio - // inherits a raw fd (logFd), but DETACHED_PROCESS already leaves this - // one without a console; the flag is kept for consistency. stdio: ["ignore", logFd, logFd], env: process.env, - windowsHide: true, }, ); } finally { diff --git a/packages/cli/src/telemetry/transport.windowsHide.test.ts b/packages/cli/src/telemetry/transport.windowsHide.test.ts deleted file mode 100644 index fde5954e2ad..00000000000 --- a/packages/cli/src/telemetry/transport.windowsHide.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() })); - -vi.mock("node:child_process", () => ({ spawn: spawnMock })); - -import { enqueue, flushSync } from "./transport.js"; - -describe("flushSync child-process options", () => { - it("hides the flush-on-exit console window on Windows", () => { - spawnMock.mockReturnValue({ unref: vi.fn() }); - enqueue("test_event", {}); - - flushSync(); - - expect(spawnMock).toHaveBeenCalledOnce(); - expect(spawnMock.mock.calls[0]?.[2]).toEqual(expect.objectContaining({ windowsHide: true })); - }); -}); diff --git a/packages/cli/src/utils/openBrowser.ts b/packages/cli/src/utils/openBrowser.ts index 5fd0f4316d8..0cde28af9d9 100644 --- a/packages/cli/src/utils/openBrowser.ts +++ b/packages/cli/src/utils/openBrowser.ts @@ -81,7 +81,6 @@ export function openBrowser(url: string, options: OpenBrowserOptions = {}): void const child = spawn(options.browserPath, args, { detached: true, stdio: "ignore", - windowsHide: true, }); child.on("error", () => {}); child.unref(); diff --git a/packages/cli/src/utils/openBrowser.windowsHide.test.ts b/packages/cli/src/utils/openBrowser.windowsHide.test.ts deleted file mode 100644 index 84adaee8db3..00000000000 --- a/packages/cli/src/utils/openBrowser.windowsHide.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -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 { openBrowser } from "./openBrowser.js"; - -describe("openBrowser child-process options", () => { - it("hides the spawned browser's console window on Windows", () => { - // `browserPath` takes the direct-spawn path, so the `open` package is - // never imported here. - const proc = Object.assign(new EventEmitter(), { unref: vi.fn() }); - spawnMock.mockReturnValue(proc); - - openBrowser("http://localhost:3002", { browserPath: "/usr/bin/chromium" }); - - expect(spawnMock.mock.calls[0]?.[2]).toEqual(expect.objectContaining({ windowsHide: true })); - }); -}); diff --git a/packages/engine/src/utils/psnr.windowsHide.test.ts b/packages/engine/src/utils/psnr.windowsHide.test.ts deleted file mode 100644 index a750938f33c..00000000000 --- a/packages/engine/src/utils/psnr.windowsHide.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { promisify } from "node:util"; -import { describe, expect, it, vi } from "vitest"; - -describe("psnrDb", () => { - it("runs ffmpeg with windowsHide so no console window flashes on Windows", async () => { - const run = vi.fn(async (_file: string, _args: readonly string[], _options: unknown) => ({ - stdout: "", - stderr: "average:30.5", - })); - // psnr.ts only ever calls `promisify(execFile)`, so the custom impl is the - // whole contract: promisified without it, the mock would look like a - // single-result callback and `{stderr}` would destructure to undefined. - const execFile = Object.assign( - () => { - throw new Error("psnr.ts must call execFile through promisify"); - }, - { [promisify.custom]: run }, - ); - vi.resetModules(); - vi.doMock("node:child_process", () => ({ execFile })); - - const { psnrDb } = await import("./psnr.js"); - await psnrDb(Buffer.from("a"), Buffer.from("b")); - - expect(run).toHaveBeenCalledOnce(); - expect(run).toHaveBeenCalledWith( - expect.any(String), - expect.any(Array), - expect.objectContaining({ windowsHide: true }), - ); - }); -}); diff --git a/packages/engine/src/utils/psnrFilterAvailability.test.ts b/packages/engine/src/utils/psnrFilterAvailability.test.ts index f14bd603269..d8a45cdcec7 100644 --- a/packages/engine/src/utils/psnrFilterAvailability.test.ts +++ b/packages/engine/src/utils/psnrFilterAvailability.test.ts @@ -4,7 +4,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; interface ExecFileCall { file: string; args: readonly string[]; - options: unknown; } type ExecFileOutcome = @@ -32,9 +31,8 @@ function createExecFileSpy(outcome: ExecFileOutcome): { async function run( file: string, args: readonly string[], - options: unknown, ): Promise<{ stdout: string; stderr: string }> { - calls.push({ file, args, options }); + calls.push({ file, args }); if (outcome.kind === "enoent") { const err = new Error("spawn ffmpeg ENOENT") as NodeJS.ErrnoException; err.code = "ENOENT"; @@ -57,10 +55,10 @@ function createExecFileSpy(outcome: ExecFileOutcome): { const execFile = (( file: string, args: readonly string[], - options: unknown, + _options: unknown, callback: (err: Error | null, stdout?: string, stderr?: string) => void, ) => { - run(file, args, options).then( + run(file, args).then( ({ stdout, stderr }) => process.nextTick(() => callback(null, stdout, stderr)), (err: Error) => process.nextTick(() => callback(err)), ); @@ -73,8 +71,7 @@ function createExecFileSpy(outcome: ExecFileOutcome): { (execFile as { [k: symbol]: unknown })[promisify.custom] = ( file: string, args: readonly string[], - options: unknown, - ) => run(file, args, options); + ) => run(file, args); return { execFile, calls }; } @@ -178,16 +175,6 @@ describe("isPsnrFilterAvailable", () => { expect(calls.length).toBe(2); }); - it("probes ffmpeg with windowsHide so no console window flashes on Windows", async () => { - const { execFile, calls } = createExecFileSpy({ kind: "ok", stdout: "psnr" }); - vi.doMock("node:child_process", () => ({ execFile })); - - const { isPsnrFilterAvailable } = await import("./psnrFilterAvailability.js"); - await isPsnrFilterAvailable(); - - expect(calls[0]?.options).toEqual(expect.objectContaining({ windowsHide: true })); - }); - it("does not treat a whole-string 'psnr' inside another word as the filter", async () => { const { execFile } = createExecFileSpy({ kind: "ok", diff --git a/packages/producer/src/services/render/audioPadTrim.ts b/packages/producer/src/services/render/audioPadTrim.ts index 61c6117de99..9b834b922b7 100644 --- a/packages/producer/src/services/render/audioPadTrim.ts +++ b/packages/producer/src/services/render/audioPadTrim.ts @@ -41,7 +41,7 @@ import { redactKnownPaths, redactTelemetryString } from "@hyperframes/core"; const AUDIO_DURATION_TOLERANCE_SECONDS = 0.001; /** Delivery headroom applied after every AAC encode in this stage. */ -const AAC_DELIVERY_TRUE_PEAK_DBFS = -1; +export const AAC_DELIVERY_TRUE_PEAK_DBFS = -1; const AAC_TRUE_PEAK_CORRECTION_HEADROOM_DB = 0.5; const MAX_TRUE_PEAK_CORRECTION_PASSES = 3; diff --git a/packages/producer/src/services/render/audioPadTrim.windowsHide.test.ts b/packages/producer/src/services/render/audioPadTrim.windowsHide.test.ts deleted file mode 100644 index 9c2c2543bf6..00000000000 --- a/packages/producer/src/services/render/audioPadTrim.windowsHide.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { EventEmitter } from "node:events"; -import { describe, expect, it, mock } from "bun:test"; - -const spawnMock = mock(() => { - const proc = new EventEmitter() as EventEmitter & { - stdout: EventEmitter; - stderr: EventEmitter; - kill: () => boolean; - }; - proc.stdout = new EventEmitter(); - proc.stderr = new EventEmitter(); - proc.kill = () => true; - queueMicrotask(() => { - proc.emit("spawn"); - proc.stdout.emit("data", Buffer.from('{"streams":[]}')); - proc.emit("close", 0, null); - }); - return proc; -}); - -const realChildProcess = await import("node:child_process"); -mock.module("node:child_process", () => ({ ...realChildProcess, spawn: spawnMock })); - -const { padOrTrimAudioToVideoFrameCount } = await import("./audioPadTrim.js"); - -describe("runFfprobeJson child-process options", () => { - it("hides the ffprobe console window when probing the video", async () => { - // The audio probe is stubbed out, so every spawn here comes from the - // real video probe running through runFfprobeJson. - await padOrTrimAudioToVideoFrameCount({ - videoPath: "/fake/video.mp4", - audioPath: "/fake/audio.m4a", - outputPath: "/fake/out.m4a", - probeAudioInfo: async () => ({ durationSeconds: 1 }), - }).catch(() => undefined); - - expect(spawnMock).toHaveBeenCalled(); - expect(spawnMock.mock.calls[0]?.[2]).toEqual(expect.objectContaining({ windowsHide: true })); - }); -}); From 21724a9784e165fab126bc9d13b68b6d874e4871 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 2 Oct 2026 08:18:24 -0700 Subject: [PATCH 5/7] fix(cli): stop the studio dev server when the terminal closes With windowsHide the dev server runs in its own hidden console on Windows, so closing the terminal only signals the wrapper (SIGHUP). Handle it like SIGINT and SIGTERM and kill the process tree. --- packages/cli/src/commands/preview.test.ts | 19 ++++++++++++++++--- packages/cli/src/commands/preview.ts | 13 +++++++------ 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/commands/preview.test.ts b/packages/cli/src/commands/preview.test.ts index 66c2fb74dec..74e08cf6946 100644 --- a/packages/cli/src/commands/preview.test.ts +++ b/packages/cli/src/commands/preview.test.ts @@ -485,8 +485,21 @@ describe("waitForStudioChildClose", () => { await expect(waitForStudioChildClose(child, signalTarget)).resolves.toBeUndefined(); expect(child.once).not.toHaveBeenCalled(); - expect(signalTarget.once).toHaveBeenCalledTimes(2); - expect(signalTarget.off).toHaveBeenCalledTimes(2); + expect(signalTarget.once).toHaveBeenCalledTimes(3); + expect(signalTarget.off).toHaveBeenCalledTimes(3); + }); + + it("reaps the dev server when the terminal closes (SIGHUP)", async () => { + const signalTarget = { once: vi.fn(), off: vi.fn() }; + const child = { exitCode: 0, signalCode: null, once: vi.fn() } as unknown as Parameters< + typeof waitForStudioChildClose + >[0]; + + await waitForStudioChildClose(child, signalTarget); + + const hupListener = signalTarget.once.mock.calls.find(([event]) => event === "SIGHUP")?.[1]; + expect(hupListener).toBeTypeOf("function"); + expect(signalTarget.off).toHaveBeenCalledWith("SIGHUP", hupListener); }); it("reaps on process exit even when stdio never emits close", async () => { @@ -512,7 +525,7 @@ describe("waitForStudioChildClose", () => { exit?.(); await waiting; expect(resolved).toBe(true); - expect(signalTarget.off).toHaveBeenCalledTimes(2); + expect(signalTarget.off).toHaveBeenCalledTimes(3); }); }); diff --git a/packages/cli/src/commands/preview.ts b/packages/cli/src/commands/preview.ts index 4322ce349dc..fce57450faa 100644 --- a/packages/cli/src/commands/preview.ts +++ b/packages/cli/src/commands/preview.ts @@ -100,9 +100,12 @@ interface EmbeddedStudioOptions extends StudioLaunchOptions { } type StudioChildProcess = ChildProcessByStdio; +// SIGHUP: closing the terminal must still reap the dev server, which runs in its own hidden console on Windows. +const STUDIO_CHILD_SHUTDOWN_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"] as const; +type StudioShutdownSignal = (typeof STUDIO_CHILD_SHUTDOWN_SIGNALS)[number]; interface StudioSignalTarget { - once(event: "SIGINT" | "SIGTERM", listener: () => void): unknown; - off(event: "SIGINT" | "SIGTERM", listener: () => void): unknown; + once(event: StudioShutdownSignal, listener: () => void): unknown; + off(event: StudioShutdownSignal, listener: () => void): unknown; } type ContextField = "server" | "selection" | "lint" | "capabilities"; type CompactSelectionPayload = Pick< @@ -1337,8 +1340,7 @@ export function waitForStudioChildClose( const shutdown = (): void => { if (child.pid) killProcessTree(child.pid); }; - signalTarget.once("SIGINT", shutdown); - signalTarget.once("SIGTERM", shutdown); + for (const signal of STUDIO_CHILD_SHUTDOWN_SIGNALS) signalTarget.once(signal, shutdown); // A short-lived Vite child can exit before launch setup reaches this point. // ChildProcess does not replay lifecycle events to listeners attached later, @@ -1355,8 +1357,7 @@ export function waitForStudioChildClose( return closed.finally(() => { // Signal listeners keep Bun's event loop alive even after Vite exits. Leaving // them registered makes `preview --stop` close the port but leak the wrapper. - signalTarget.off("SIGINT", shutdown); - signalTarget.off("SIGTERM", shutdown); + for (const signal of STUDIO_CHILD_SHUTDOWN_SIGNALS) signalTarget.off(signal, shutdown); }); } From 12ec0bdfe48f1e3df95316048a94911f50083e6c Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 2 Oct 2026 08:18:27 -0700 Subject: [PATCH 6/7] fix(studio-server): hide the console window for loudness measurement The background preview has no console, so its ffmpeg loudness run opened a visible window on Windows. --- .../studio-server/src/helpers/loudness.ts | 1 + .../src/helpers/loudness.windowsHide.test.ts | 25 +++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 packages/studio-server/src/helpers/loudness.windowsHide.test.ts diff --git a/packages/studio-server/src/helpers/loudness.ts b/packages/studio-server/src/helpers/loudness.ts index 75c8790deac..ba1cf1e473c 100644 --- a/packages/studio-server/src/helpers/loudness.ts +++ b/packages/studio-server/src/helpers/loudness.ts @@ -468,6 +468,7 @@ export async function measureAudio( encoding: "utf8", maxBuffer: 16 * 1024 * 1024, timeout: FFMPEG_TIMEOUT_MS, + windowsHide: true, }); return parseEbur128Summary(result.stderr); } diff --git a/packages/studio-server/src/helpers/loudness.windowsHide.test.ts b/packages/studio-server/src/helpers/loudness.windowsHide.test.ts new file mode 100644 index 00000000000..200954d589d --- /dev/null +++ b/packages/studio-server/src/helpers/loudness.windowsHide.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from "vitest"; + +const { execFileMock } = vi.hoisted(() => ({ + execFileMock: vi.fn( + (_file: string, _args: string[], _options: unknown, callback: (error: Error) => void) => + callback(new Error("ffmpeg stub")), + ), +})); + +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, default: { ...actual, execFile: execFileMock }, execFile: execFileMock }; +}); + +import { measureAudio } from "./loudness.js"; + +describe("measureAudio child-process options", () => { + it("hides the ffmpeg console window on Windows", async () => { + await expect( + measureAudio("/fake/ffmpeg", "/fake/a.wav", { mediaStart: 0, duration: 1 }), + ).rejects.toThrow(); + + expect(execFileMock.mock.calls[0]?.[2]).toEqual(expect.objectContaining({ windowsHide: true })); + }); +}); From 22156fb5429bcf83edb0f96b3704cdd4fde9f863 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 2 Oct 2026 09:11:12 -0700 Subject: [PATCH 7/7] refactor(cli): drop the comment on the studio shutdown signals --- packages/cli/src/commands/preview.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/cli/src/commands/preview.ts b/packages/cli/src/commands/preview.ts index fce57450faa..92ce2bbe433 100644 --- a/packages/cli/src/commands/preview.ts +++ b/packages/cli/src/commands/preview.ts @@ -100,7 +100,6 @@ interface EmbeddedStudioOptions extends StudioLaunchOptions { } type StudioChildProcess = ChildProcessByStdio; -// SIGHUP: closing the terminal must still reap the dev server, which runs in its own hidden console on Windows. const STUDIO_CHILD_SHUTDOWN_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"] as const; type StudioShutdownSignal = (typeof STUDIO_CHILD_SHUTDOWN_SIGNALS)[number]; interface StudioSignalTarget {