Skip to content

Fix Windows launcher race condition - #375

Closed
otnc wants to merge 1 commit into
sindresorhus:mainfrom
otnc:fix/windows-launcher-race
Closed

otnc wants to merge 1 commit into
sindresorhus:mainfrom
otnc:fix/windows-launcher-race

Conversation

@otnc

@otnc otnc commented Sep 4, 2026

Copy link
Copy Markdown

Problem

On Windows, open() (without wait: true) can silently fail to open anything, even though it resolves without throwing.

This reproduces with a plain call like:

import open from 'open';

await open(String.raw`C:\path\to\file.html`, {app: {name: 'brave'}});
console.log('done');
// -> resolves without error, but the browser never opens

This is not specific to any particular runner (npm scripts, npx, etc.) — it reproduces with a bare node invocation that does nothing after the await.

Root cause

On Windows, the actual command is always wrapped in a powershell.exe launcher that runs a base64-encoded Start-Process ... script. The comment above the non-fallback resolution path assumes:

The launcher (open/xdg-open/PowerShell) exits quickly (~10-30ms) even on success.

That assumption holds for macOS open and xdg-open, but not for legacy Windows PowerShell (System32\WindowsPowerShell\v1.0\powershell.exe, which powerShellPath() always targets — Windows still isn't defaulting new processes to pwsh, so this is the common case, not an edge case). Starting it up (loading the runtime, applying -ExecutionPolicy Bypass, decoding and parsing the script) routinely takes several hundred ms, well past that ~10-30ms assumption.

The non-fallback, non-wait path only waits for the 'spawn' event before resolving:

subprocess.once('spawn', () => {
	subprocess.off('error', reject);
	resolve(subprocess);
});

'spawn' only means the OS handed back a process handle for powershell.exe — not that it has finished executing the encoded Start-Process command yet. If the caller does nothing meaningful after await open(...) (a very common pattern, e.g. a short-lived CLI script), the process can exit right after this resolves. Since the Windows branch never sets detached: true on childProcessOptions (only the Linux xdg-open branch does), the still-starting powershell.exe child is not detached from the caller, and can be torn down before it ever gets to run Start-Process.

I confirmed this directly: replacing the spawn-based resolution with one that waits for the launcher's 'close' event (what the existing isFallbackAttempt path already does) makes it succeed reliably, taking ~200-900ms depending on system load — consistent with legacy PowerShell startup cost, not with the app itself.

Related: #298 (closed as "probably fixed" by 966239c, which added the 'spawn' wait — that made things better but didn't fully close the race on Windows specifically, for the reason above). Also related: #144, #189.

Fix

Reuse the existing isFallbackAttempt handling (which already waits for the launcher's 'close' event before resolving) for the Windows path too, since Windows always goes through this same kind of short-lived launcher process. This does not wait for the opened app to close — only for the (still fast, just not ~10-30ms fast) launcher script that Start-Process runs in. It's unrelated to options.wait, which additionally passes -Wait to Start-Process itself to block until the opened app exits.

Testing

  • xo passes on the changed files.
  • Added a Windows-specific ava test mirroring the existing 'app launches resolve before close without fallback' test, but asserting the opposite (resolves after close) — gated by process.platform === 'win32', leaving the non-Windows test and behavior untouched.
  • Manually verified against a real brave launch on Windows 11: without this change, the browser did not open; with it, open() waits for the launcher and the browser reliably opens.

Fixes #298

On Windows, the target command always runs through a `powershell.exe`
launcher, which can take noticeably longer than the ~10-30ms assumed
for other platforms' launchers to start executing. The non-fallback
path only waited for the `spawn` event before resolving and letting
the caller's process exit, so a caller exiting right after `await
open(...)` could tear down the still-starting, non-detached launcher
before it ran `Start-Process` and actually opened the target app.

Reuse the existing fallback-attempt handling, which already waits for
the launcher's `close` event, for the Windows path as well. This does
not wait for the opened app itself, only for the launcher script.

Fixes sindresorhus#298
@sindresorhus

Copy link
Copy Markdown
Owner

Closing in favor of 734b821

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Issue: Windows not opening browser or any client application without wait: true or setting a long timeout

2 participants