fix(windows): start the WSL holder with only a NUL handle to inherit (#35799)

* fix(windows): start the WSL holder with only a NUL handle to inherit

libuv starts every Windows child with bInheritHandles=TRUE and no handle
list, so a detached holder that outlives Orca kept any inheritable handle
Orca had open, such as a launcher's pipe or a DevTools socket. Start it
through CreateProcessW with PROC_THREAD_ATTRIBUTE_HANDLE_LIST naming one NUL
handle for its stdio. The build probes now check the newest export without
calling it, and the lane receipt labels ConPTY's own startup output.

* fix(windows): report isolated holder exits and kills the way libuv does
This commit is contained in:
Neil
2026-10-29 02:56:09 -07:00
committed by GitHub
parent e0f05c3fc9
commit 9f62a4fa2d
26 changed files with 827 additions and 20 deletions
@@ -54,7 +54,7 @@ runs:
env:
NATIVE_RUNTIME: ${{ inputs.native-runtime }}
NODE_VERSION: ${{ inputs.node-version }}
NATIVE_SOURCE_HASH: ${{ hashFiles('.npmrc', 'config/pnpmfile.cjs', '.github/actions/prepare-native-runtime/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/initialize-windows-native-toolchain.mjs', 'config/scripts/capture-windows-native-environment.cmd', 'config/scripts/rebuild-native-deps.mjs', 'config/scripts/node-pty-job-ownership.cjs', 'config/scripts/windows-pe-machine.cjs', 'config/scripts/windows-process-tree-gyp-rebuild.mjs', 'config/scripts/windows-process-tree-creation-time.cjs', 'config/scripts/install-electron-package-binary.mjs', 'config/scripts/electron-platform-path.mjs', 'config/scripts/zip-extractor-command.mjs', 'src/shared/zip-extractor-command.ts', 'config/scripts/shared-electron-dist-cache.mjs', 'config/scripts/space-sharing-copy.mjs', 'config/scripts/shared-tree-publish.mjs', 'config/scripts/native-build-entry.cjs', 'config/scripts/native-build-entry-probe.cjs', 'config/scripts/shared-native-build.mjs', 'config/scripts/orcad-prebuild-node-gyp.mjs', 'config/scripts/msbuild-file-tracking.mjs', 'config/scripts/native-dependency-lock-digest.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch', 'native/windows-registry/src/addon.cc', 'native/windows-registry/binding.gyp', 'native/windows-registry/package.json', 'native/windows-registry/index.js', 'native/windows-security/src/addon.cc', 'native/windows-security/binding.gyp', 'native/windows-security/package.json', 'native/windows-security/index.js') }}
NATIVE_SOURCE_HASH: ${{ hashFiles('.npmrc', 'config/pnpmfile.cjs', '.github/actions/prepare-native-runtime/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/initialize-windows-native-toolchain.mjs', 'config/scripts/capture-windows-native-environment.cmd', 'config/scripts/rebuild-native-deps.mjs', 'config/scripts/node-pty-job-ownership.cjs', 'config/scripts/windows-pe-machine.cjs', 'config/scripts/windows-process-tree-gyp-rebuild.mjs', 'config/scripts/windows-process-tree-creation-time.cjs', 'config/scripts/install-electron-package-binary.mjs', 'config/scripts/electron-platform-path.mjs', 'config/scripts/zip-extractor-command.mjs', 'src/shared/zip-extractor-command.ts', 'config/scripts/shared-electron-dist-cache.mjs', 'config/scripts/space-sharing-copy.mjs', 'config/scripts/shared-tree-publish.mjs', 'config/scripts/native-build-entry.cjs', 'config/scripts/native-build-entry-probe.cjs', 'config/scripts/shared-native-build.mjs', 'config/scripts/orcad-prebuild-node-gyp.mjs', 'config/scripts/msbuild-file-tracking.mjs', 'config/scripts/native-dependency-lock-digest.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch', 'native/windows-registry/src/addon.cc', 'native/windows-registry/binding.gyp', 'native/windows-registry/package.json', 'native/windows-registry/index.js', 'native/windows-security/src/addon.cc', 'native/windows-security/src/isolated-spawn.cc', 'native/windows-security/src/isolated-spawn.h', 'native/windows-security/binding.gyp', 'native/windows-security/package.json', 'native/windows-security/index.js') }}
run: |
scope="${ImageOS:-$RUNNER_OS}"
if [ -r /etc/os-release ]; then
+5 -2
View File
@@ -295,8 +295,11 @@ function loadNativeModule(moduleName) {
return
}
if (moduleName === '@orca/windows-security') {
// Why: the addon loads on first call; the newest export also rejects a stale build that loads.
require(moduleName).clearStdHandleInheritance()
const security = require(moduleName)
// currentUserSid loads the addon; a stale build that still loads lacks the newest export.
if (!security.currentUserSid() || !security.hasNativeExport('spawnDetachedIsolated')) {
throw new Error('stale build without the newest export; rebuild it')
}
return
}
if (moduleName === 'node-pty') {
@@ -425,7 +425,7 @@ function writeFakeWindowsRegistry(projectDir, { requiresMarker = false } = {}) {
mkdirSync(securityDir, { recursive: true })
writeFileSync(
join(securityDir, 'index.js'),
"exports.currentUserSid = () => 'S-1-5-18'; exports.clearStdHandleInheritance = () => []\n"
"exports.currentUserSid = () => 'S-1-5-18'; exports.hasNativeExport = () => true\n"
)
}
+1
View File
@@ -96,6 +96,7 @@ export const WINDOWS_BOUNDARY_TESTS = [
'src/main/startup/windows-install-dir-acl-repair.win32.test.ts',
'src/main/startup/windows-app-data-path.test.ts',
'src/main/startup/windows-std-handle-inheritance.win32.test.ts',
'src/main/startup/windows-isolated-spawner.win32.test.ts',
'src/main/runtime/repo-worktree-admin-fingerprint.test.ts',
'src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts',
'src/shared/secure-file-fsync-flags.test.ts',
@@ -402,7 +402,7 @@ export function writeFakeWindowsSecurity(projectDir) {
mkdirSync(securityDir, { recursive: true })
writeFileSync(
join(securityDir, 'index.js'),
"exports.currentUserSid = () => 'S-1-5-18'; exports.clearStdHandleInheritance = () => []\n"
"exports.currentUserSid = () => 'S-1-5-18'; exports.hasNativeExport = () => true\n"
)
}
+2 -2
View File
@@ -577,8 +577,8 @@ function loadNativeModule(moduleName) {
return
}
if (moduleName === '@orca/windows-security') {
// Why: the addon loads on first call; the newest export also rejects a stale build that loads.
projectRequire(moduleName).clearStdHandleInheritance()
const security = projectRequire(moduleName)
if (!security.currentUserSid() || !security.hasNativeExport('spawnDetachedIsolated')) throw new Error('stale build without the newest export; rebuild it')
return
}
if (moduleName === 'node-pty') {
+1 -1
View File
@@ -2,7 +2,7 @@
"targets": [
{
"target_name": "orca_windows_security",
"sources": ["src/addon.cc"],
"sources": ["src/addon.cc", "src/isolated-spawn.cc"],
"libraries": ["advapi32.lib"],
"include_dirs": ["<!@(node -p \"require('node-addon-api').include\")"],
"defines": ["NAPI_DISABLE_CPP_EXCEPTIONS", "UNICODE", "_UNICODE"],
+20
View File
@@ -59,3 +59,23 @@ export type StdHandleInheritanceFailure = { stream: 'stdin' | 'stdout' | 'stderr
* handles SetHandleInformation rejected (e.g. a legacy console pseudo-handle) with their Win32 code.
*/
export declare function clearStdHandleInheritance(): StdHandleInheritanceFailure[]
/** `exitWatchError` is the Win32 code when the exit can't be observed; the child still runs. */
export type IsolatedSpawnResult = { pid: number; exitWatchError: number | null }
/**
* Starts `file` detached and hidden, inheriting only a NUL handle for its stdio, so none of this
* process's other inheritable handles reach it. `commandLine` is the full, already quoted line
* (argv[0] included); an empty `cwd` keeps ours; `environmentBlock` is `K=V\0…\0\0`. `onExit` gets the exit code and never
* keeps the event loop alive. Throws an Error naming the failed Win32 call.
*/
export declare function spawnDetachedIsolated(
file: string,
commandLine: string,
cwd: string,
environmentBlock: string,
onExit: (code: number) => void
): IsolatedSpawnResult
/** Whether the resolved binary exports `name`; loads it, but calls nothing else. */
export declare function hasNativeExport(name: string): boolean
+5 -1
View File
@@ -18,5 +18,9 @@ module.exports = {
grantAccess: (path, sid, mask, options) => getAddon().grantAccess(path, sid, mask, options),
grantAccessAsync: (path, sid, mask, options) =>
getAddon().grantAccessAsync(path, sid, mask, options),
clearStdHandleInheritance: () => getAddon().clearStdHandleInheritance()
clearStdHandleInheritance: () => getAddon().clearStdHandleInheritance(),
spawnDetachedIsolated: (file, commandLine, cwd, environmentBlock, onExit) =>
getAddon().spawnDetachedIsolated(file, commandLine, cwd, environmentBlock, onExit),
// Reads the binding this package resolved, so a build probe tests the binary the app loads.
hasNativeExport: (name) => typeof getAddon()[name] === 'function'
}
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@orca/windows-security",
"version": "1.0.0",
"private": true,
"description": "In-process Windows file DACL reads and writes for Orca",
"description": "In-process Windows file DACLs and child handle inheritance for Orca",
"license": "MIT",
"files": [
"index.js",
+3
View File
@@ -6,6 +6,8 @@
#include <aclapi.h>
#include <sddl.h>
#include "isolated-spawn.h"
#include <functional>
#include <memory>
#include <string>
@@ -423,6 +425,7 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) {
exports.Set("grantAccess", Napi::Function::New(env, GrantAccessJs));
exports.Set("grantAccessAsync", Napi::Function::New(env, GrantAccessAsync));
exports.Set("clearStdHandleInheritance", Napi::Function::New(env, ClearStdHandleInheritance));
exports.Set("spawnDetachedIsolated", Napi::Function::New(env, SpawnDetachedIsolated));
return exports;
}
@@ -0,0 +1,126 @@
// libuv spawns with bInheritHandles=TRUE and no handle list, so every inheritable handle in Orca
// reaches a long-lived child. This spawn lists exactly one NUL handle, used for all three stdio.
// Flags mirror libuv's detached spawn; no PROC_THREAD_ATTRIBUTE_PARENT_PROCESS, no breakaway.
#include "isolated-spawn.h"
#include <windows.h>
#include <string>
#include <vector>
namespace {
struct ExitWatch {
HANDLE process = nullptr;
HANDLE wait = nullptr;
Napi::ThreadSafeFunction onExit;
};
// Runs on a wait thread once the process is signaled; hands the code to the JS thread.
void CALLBACK OnProcessExited(PVOID context, BOOLEAN /*timedOut*/) {
auto* watch = static_cast<ExitWatch*>(context);
DWORD code = 0;
if (!GetExitCodeProcess(watch->process, &code)) {
code = static_cast<DWORD>(-1);
}
watch->onExit.NonBlockingCall([watch, code](Napi::Env env, Napi::Function callback) {
// Non-blocking unregister: the callback already ran once (WT_EXECUTEONLYONCE).
UnregisterWait(watch->wait);
CloseHandle(watch->process);
if (env != nullptr && !callback.IsEmpty()) {
callback.Call({Napi::Number::New(env, static_cast<double>(code))});
}
watch->onExit.Release();
delete watch;
});
}
Napi::Value ThrowWin32(Napi::Env env, const char* syscall, DWORD code) {
Napi::Error::New(env, std::string(syscall) + " failed with Win32 error " + std::to_string(code))
.ThrowAsJavaScriptException();
return env.Undefined();
}
} // namespace
// spawnDetachedIsolated(file, commandLine, cwd, environmentBlock, onExit(code)) -> { pid, exitWatchError }
// The exit callback never keeps the event loop alive.
Napi::Value SpawnDetachedIsolated(const Napi::CallbackInfo& info) {
auto env = info.Env();
if (info.Length() < 5 || !info[0].IsString() || !info[1].IsString() || !info[2].IsString() ||
!info[3].IsString() || !info[4].IsFunction()) {
Napi::TypeError::New(
env, "spawnDetachedIsolated(file, commandLine, cwd, environmentBlock, onExit) expected")
.ThrowAsJavaScriptException();
return env.Undefined();
}
std::u16string file = info[0].As<Napi::String>().Utf16Value();
std::u16string commandLine = info[1].As<Napi::String>().Utf16Value();
std::u16string cwd = info[2].As<Napi::String>().Utf16Value();
std::u16string environment = info[3].As<Napi::String>().Utf16Value();
SECURITY_ATTRIBUTES inheritable{sizeof(SECURITY_ATTRIBUTES), nullptr, TRUE};
HANDLE nul = CreateFileW(L"NUL", GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE,
&inheritable, OPEN_EXISTING, 0, nullptr);
if (nul == INVALID_HANDLE_VALUE) {
return ThrowWin32(env, "CreateFileW", GetLastError());
}
SIZE_T size = 0;
InitializeProcThreadAttributeList(nullptr, 1, 0, &size);
std::vector<unsigned char> storage(size);
auto* attributes = reinterpret_cast<LPPROC_THREAD_ATTRIBUTE_LIST>(storage.data());
if (!InitializeProcThreadAttributeList(attributes, 1, 0, &size)) {
DWORD error = GetLastError();
CloseHandle(nul);
return ThrowWin32(env, "InitializeProcThreadAttributeList", error);
}
if (!UpdateProcThreadAttribute(attributes, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, &nul, sizeof(nul),
nullptr, nullptr)) {
DWORD error = GetLastError();
DeleteProcThreadAttributeList(attributes);
CloseHandle(nul);
return ThrowWin32(env, "UpdateProcThreadAttribute", error);
}
STARTUPINFOEXW startup{};
startup.StartupInfo.cb = sizeof(startup);
startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES | STARTF_USESHOWWINDOW;
startup.StartupInfo.wShowWindow = SW_HIDE;
startup.StartupInfo.hStdInput = nul;
startup.StartupInfo.hStdOutput = nul;
startup.StartupInfo.hStdError = nul;
startup.lpAttributeList = attributes;
PROCESS_INFORMATION created{};
const DWORD flags = CREATE_UNICODE_ENVIRONMENT | DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP |
EXTENDED_STARTUPINFO_PRESENT;
BOOL ok = CreateProcessW(reinterpret_cast<LPCWSTR>(file.c_str()),
reinterpret_cast<LPWSTR>(commandLine.data()), nullptr, nullptr, TRUE,
flags, environment.data(),
cwd.empty() ? nullptr : reinterpret_cast<LPCWSTR>(cwd.c_str()),
&startup.StartupInfo, &created);
DWORD error = GetLastError();
DeleteProcThreadAttributeList(attributes);
CloseHandle(nul);
if (!ok) {
return ThrowWin32(env, "CreateProcessW", error);
}
CloseHandle(created.hThread);
auto result = Napi::Object::New(env);
result.Set("pid", static_cast<double>(created.dwProcessId));
auto* watch = new ExitWatch{created.hProcess};
watch->onExit = Napi::ThreadSafeFunction::New(env, info[4].As<Napi::Function>(),
"orcaIsolatedSpawnExit", 0, 1);
watch->onExit.Unref(env);
if (!RegisterWaitForSingleObject(&watch->wait, created.hProcess, OnProcessExited, watch, INFINITE,
WT_EXECUTEONLYONCE)) {
// The child runs on; only its exit goes unobserved.
result.Set("exitWatchError", static_cast<double>(GetLastError()));
watch->onExit.Release();
CloseHandle(created.hProcess);
delete watch;
return result;
}
result.Set("exitWatchError", env.Null());
return result;
}
@@ -0,0 +1,5 @@
#pragma once
#include <napi.h>
Napi::Value SpawnDetachedIsolated(const Napi::CallbackInfo& info);
+2
View File
@@ -41,6 +41,7 @@ import { configureWslHostsGateFromDisk } from './wsl/wsl-host-gate-startup'
import { ignoreInheritedWslHostsGateState } from '../shared/wsl-host-internal-gate'
import { registerOrcaProtocolClient } from './startup/orca-protocol-registration'
import { clearWindowsStdHandleInheritance } from './startup/windows-std-handle-inheritance'
import { installWindowsIsolatedSpawner } from './startup/windows-isolated-spawner'
function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): BrowserWindow {
return openMainWindowController(options)
@@ -110,6 +111,7 @@ const handleMacAppActivation = createMacAppActivationHandler({
// Before anything spawns: no child may inherit Orca's std handles.
clearWindowsStdHandleInheritance()
installWindowsIsolatedSpawner()
// A relaunch or installer inherits the parent's published WSL gate state; this process recomputes.
ignoreInheritedWslHostsGateState()
// The uninstaller's holder release and hook removal run instead of the app, never alongside it.
@@ -43,6 +43,8 @@ function installFakeAddon(
createFileWithDacl: unexpected,
grantAccess: unexpected,
clearStdHandleInheritance: unexpected,
spawnDetachedIsolated: unexpected,
hasNativeExport: unexpected,
grantAccessAsync: async (path, sid, accessMask, options) => {
calls.push({ path: plain(path), sid, accessMask, options })
if (fail(plain(path))) {
@@ -191,6 +193,8 @@ describe('install-dir package grant passes', () => {
createFileWithDacl: unexpected,
grantAccess: unexpected,
clearStdHandleInheritance: unexpected,
spawnDetachedIsolated: unexpected,
hasNativeExport: unexpected,
grantAccessAsync: async (path) => {
seen.push(path)
}
@@ -0,0 +1,29 @@
// Electron main entry for windows-isolated-spawner.win32.test.ts: Electron keeps its std handles
// inheritable, so a holder spawned through libuv pins the test's pipe and an isolated one must not.
import { app } from 'electron'
import { writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import type * as WindowsSecurity from '@orca/windows-security'
import {
setIsolatedSpawner,
spawnDetachedWithoutInheritance
} from '@orca/process-host/isolated-detached-spawn'
const [addonPath, mode, holderMs, resultFile] = process.argv.slice(-4)
const addon: typeof WindowsSecurity = createRequire(__filename)(addonPath)
if (mode === 'isolated') {
setIsolatedSpawner(addon.spawnDetachedIsolated)
}
const holder = spawnDetachedWithoutInheritance({
program: process.execPath,
args: ['-e', `setTimeout(() => {}, ${holderMs})`],
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }
})
holder.once('error', (error) => {
writeFileSync(resultFile, `error ${String(error)}`)
app.exit(1)
})
holder.once('spawn', () => {
writeFileSync(resultFile, String(holder.pid))
app.exit(0)
})
@@ -0,0 +1,69 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
const { loadWindowsSecurity, setIsolatedSpawner } = vi.hoisted(() => ({
loadWindowsSecurity: vi.fn(),
setIsolatedSpawner: vi.fn()
}))
vi.mock('../../shared/windows-security-addon', () => ({ loadWindowsSecurity }))
vi.mock('@orca/process-host/isolated-detached-spawn', () => ({ setIsolatedSpawner }))
import { installWindowsIsolatedSpawner } from './windows-isolated-spawner'
function addonWith(hasNativeExport: (name: string) => boolean) {
const spawnDetachedIsolated = vi.fn()
return {
spawnDetachedIsolated,
security: { userSid: 'S-1-5-18', addon: { hasNativeExport, spawnDetachedIsolated } }
}
}
describe('installWindowsIsolatedSpawner', () => {
afterEach(() => {
vi.restoreAllMocks()
loadWindowsSecurity.mockReset()
setIsolatedSpawner.mockReset()
})
it('is a no-op off Windows', () => {
installWindowsIsolatedSpawner('darwin')
expect(loadWindowsSecurity).not.toHaveBeenCalled()
expect(setIsolatedSpawner).not.toHaveBeenCalled()
})
it('installs the native spawner when the binary exports it', () => {
const { security, spawnDetachedIsolated } = addonWith(
(name) => name === 'spawnDetachedIsolated'
)
loadWindowsSecurity.mockReturnValue(security)
installWindowsIsolatedSpawner('win32')
expect(setIsolatedSpawner).toHaveBeenCalledWith(spawnDetachedIsolated)
})
it('keeps libuv and logs when the binary is stale', () => {
loadWindowsSecurity.mockReturnValue(addonWith(() => false).security)
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
installWindowsIsolatedSpawner('win32')
expect(setIsolatedSpawner).not.toHaveBeenCalled()
expect(warn).toHaveBeenCalledWith(
'[startup] detached spawns inherit handles; the addon build is stale'
)
})
it('keeps libuv and logs when the addon is unavailable or throws', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
loadWindowsSecurity.mockReturnValueOnce({ error: 'missing binary' })
loadWindowsSecurity.mockImplementationOnce(() => {
throw new Error('boom')
})
expect(() => installWindowsIsolatedSpawner('win32')).not.toThrow()
expect(() => installWindowsIsolatedSpawner('win32')).not.toThrow()
expect(setIsolatedSpawner).not.toHaveBeenCalled()
expect(warn).toHaveBeenCalledTimes(2)
})
})
@@ -0,0 +1,26 @@
import { setIsolatedSpawner } from '@orca/process-host/isolated-detached-spawn'
import { loadWindowsSecurity } from '../../shared/windows-security-addon'
/** Long-lived detached children (the WSL holder) then inherit none of main's handles, e.g. a
* DevTools socket. A missing or stale addon keeps libuv's spawn, which inherits them. */
export function installWindowsIsolatedSpawner(platform: NodeJS.Platform = process.platform): void {
if (platform !== 'win32') {
return
}
try {
const security = loadWindowsSecurity()
if ('error' in security) {
console.warn(
`[startup] detached spawns inherit handles; addon unavailable: ${security.error}`
)
return
}
if (!security.addon.hasNativeExport('spawnDetachedIsolated')) {
console.warn('[startup] detached spawns inherit handles; the addon build is stale')
return
}
setIsolatedSpawner(security.addon.spawnDetachedIsolated)
} catch (error) {
console.warn('[startup] detached spawns inherit handles:', error)
}
}
@@ -0,0 +1,156 @@
import { build } from 'esbuild'
import { once } from 'node:events'
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { spawnProcess } from '@orca/process-host'
import {
setIsolatedSpawner,
spawnDetachedWithoutInheritance
} from '@orca/process-host/isolated-detached-spawn'
import { loadWindowsSecurity } from '../../shared/windows-security-addon'
const describeOnWindows = process.platform === 'win32' ? describe : describe.skip
const ADDON = resolve(__dirname, '..', '..', '..', 'native', 'windows-security', 'index.js')
const root = mkdtempSync(join(tmpdir(), 'orca-isolated-spawn-'))
const entry = join(root, 'main.cjs')
function isAlive(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
/** Runs the Electron fixture; reports whether its holder outlived the fixture's stdout pipe. */
async function runElectron(mode: 'isolated' | 'libuv', holderMs: number) {
const electron: unknown = createRequire(import.meta.url)('electron')
if (typeof electron !== 'string') {
throw new Error('Electron executable is unavailable')
}
const env: NodeJS.ProcessEnv = { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }
delete env.ELECTRON_RUN_AS_NODE
const resultFile = join(root, `${mode}.result`)
const child = spawnProcess({
program: electron,
args: [entry, ADDON, mode, String(holderMs), resultFile],
env,
stdio: ['ignore', 'pipe', 'pipe']
})
let output = ''
child.stdout?.on('data', (chunk) => (output += chunk))
child.stderr?.on('data', (chunk) => (output += chunk))
const [code] = await once(child, 'close', { signal: AbortSignal.timeout(holderMs + 30_000) })
const result = readFileSync(resultFile, 'utf8')
const holderPid = Number(result)
const holderAliveAtClose = isAlive(holderPid)
if (holderAliveAtClose) {
process.kill(holderPid)
}
return { code, output, result, holderAliveAtClose }
}
describeOnWindows('isolated detached spawn against real Windows processes', () => {
beforeAll(async () => {
await build({
entryPoints: [resolve(__dirname, 'windows-isolated-spawn-electron-fixture.ts')],
outfile: entry,
bundle: true,
platform: 'node',
format: 'cjs',
conditions: ['orca-source'],
external: ['electron'],
logLevel: 'silent'
})
const security = loadWindowsSecurity()
if ('error' in security) {
throw new Error(`the windows-security addon did not load: ${security.error}`)
}
setIsolatedSpawner(security.addon.spawnDetachedIsolated)
})
afterAll(() => {
setIsolatedSpawner(null)
rmSync(root, { recursive: true, force: true })
})
// A cold Node start on a loaded Windows runner can exceed the 5 s default.
it('passes argv through unchanged and reports the exit code', async () => {
const argvFile = join(root, 'argv.json')
const tricky = ['plain', 'with space', 'quote"inside', 'back\\slash', 'trail\\', 'x \\"y', '']
const child = spawnDetachedWithoutInheritance({
program: process.execPath,
args: [
'-e',
'require("fs").writeFileSync(process.argv[1], JSON.stringify(process.argv.slice(2))); process.exit(7)',
argvFile,
...tricky
],
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }
})
const [code] = await once(child, 'exit', { signal: AbortSignal.timeout(20_000) })
expect(code).toBe(7)
expect(JSON.parse(readFileSync(argvFile, 'utf8'))).toEqual(tricky)
}, 30_000)
// Holder liveness reads these: exit 1 means its distro stopped (or someone else killed it).
it.each(['isolated', 'libuv'] as const)(
'%s spawn: own kill reports SIGTERM, an outside kill reports exit 1',
async (mode) => {
const start = () => {
const spec = {
program: process.execPath,
args: ['-e', 'setTimeout(() => {}, 60000)'],
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }
}
return mode === 'isolated'
? spawnDetachedWithoutInheritance(spec)
: spawnProcess({ ...spec, detached: true, stdio: 'ignore' })
}
const own = start()
const outside = start()
await Promise.all([once(own, 'spawn'), once(outside, 'spawn')])
const ownExit = once(own, 'exit', { signal: AbortSignal.timeout(10_000) })
const outsideExit = once(outside, 'exit', { signal: AbortSignal.timeout(10_000) })
expect(own.kill()).toBe(true)
process.kill(Number(outside.pid))
expect(await ownExit).toEqual([null, 'SIGTERM'])
expect(await outsideExit).toEqual([1, null])
},
// Two cold Node starts on a loaded Windows runner can exceed the 5 s default.
20_000
)
it('reports a missing program as an error event', async () => {
const child = spawnDetachedWithoutInheritance({
program: join(root, 'missing.exe')
})
const [error] = await once(child, 'error', { signal: AbortSignal.timeout(5_000) })
expect(String(error)).toContain('CreateProcessW failed with Win32 error 2')
})
// Cold Electron start on a CI runner; a regression pins the pipe for the 60 s holder.
it('keeps an Electron main pipe away from a detached holder', async () => {
const run = await runElectron('isolated', 60_000)
expect(run.code, run.output).toBe(0)
expect(run.result).toMatch(/^\d+$/)
expect(run.holderAliveAtClose).toBe(true)
}, 90_000)
// Cold Electron start on a CI runner plus the 3 s holder exceeds the 5 s default.
it('without it, the libuv holder pins the pipe until it exits (control)', async () => {
const run = await runElectron('libuv', 3_000)
expect(run.code, run.output).toBe(0)
expect(run.holderAliveAtClose).toBe(false)
}, 60_000)
})
+4 -5
View File
@@ -1,5 +1,5 @@
import { runKeyedSerializedOperation } from '../cli/keyed-promise-queue'
import { spawnProcess } from '@orca/process-host'
import { spawnDetachedWithoutInheritance } from '@orca/process-host/isolated-detached-spawn'
import { queryWindowsProcess } from '../daemon/daemon-process-inspection'
import {
listConfirmedRunningWslDistros,
@@ -42,14 +42,13 @@ export const WSL_HOLDER_UNVERIFIABLE_REPLACE_AFTER_MS = 5 * 60_000
function spawnDetachedHolder(distro: string): WslDistroHolderChild {
// Detached holders outlive Orca's libuv kill-on-close job; explicit taskkill /T still reaches descendants.
return spawnProcess({
// Inheriting none of Orca's handles keeps a launcher's pipe or a DevTools socket from outliving Orca.
return spawnDetachedWithoutInheritance({
program: resolveWslExecutablePath(),
args: buildWslDistroHolderArgs(distro),
// A host-wide holder must never pin a worktree as its Windows cwd.
cwd: resolveWslInteropSpawnCwd(),
env: { ...process.env, WSL_UTF8: '1' },
detached: true,
stdio: 'ignore'
env: { ...process.env, WSL_UTF8: '1' }
})
}
@@ -483,9 +483,10 @@ async function restartThroughBridgeConpty(): Promise<Receipt> {
}
)
let output = ''
let firstPaintMs: number | null = null
// ConPTY's own startup repaint, before the distro has produced anything.
let conptyStartupOutputMs: number | null = null
terminal.onData((data) => {
firstPaintMs ??= Math.round(performance.now() - started)
conptyStartupOutputMs ??= Math.round(performance.now() - started)
output += data
})
try {
@@ -496,7 +497,7 @@ async function restartThroughBridgeConpty(): Promise<Receipt> {
120_000,
50
)
return { firstPaintMs, firstOutputMs: Math.round(performance.now() - started) }
return { conptyStartupOutputMs, firstOutputMs: Math.round(performance.now() - started) }
} finally {
terminal.kill()
}
@@ -616,7 +617,7 @@ async function distroRestartBaseline(): Promise<void> {
medianHostPhases: medianWslOrcadEnsurePhases(host.map((run) => run.phases)),
medianBridgeExecLowerBoundMs: median(bridge, 'firstOutputMs'),
bridgeConpty: conpty,
medianBridgeConptyFirstPaintMs: median(conpty, 'firstPaintMs'),
medianBridgeConptyStartupOutputMs: median(conpty, 'conptyStartupOutputMs'),
medianBridgeConptyFirstOutputMs: median(conpty, 'firstOutputMs')
}
// One unrecorded connect boots the distro and orcad the warm runs reconnect to.
+5
View File
@@ -20,6 +20,11 @@
"types": "./dist/process-spec.d.ts",
"default": "./dist/process-spec.js"
},
"./isolated-detached-spawn": {
"orca-source": "./src/isolated-detached-spawn.ts",
"types": "./dist/isolated-detached-spawn.d.ts",
"default": "./dist/isolated-detached-spawn.js"
},
"./fork-process": {
"orca-source": "./src/fork-process.ts",
"types": "./dist/fork-process.d.ts",
@@ -0,0 +1,190 @@
import { once } from 'node:events'
import { afterEach, describe, expect, it, vi } from 'vitest'
const { spawnProcess } = vi.hoisted(() => ({ spawnProcess: vi.fn() }))
vi.mock('./run-process', () => ({ spawnProcess }))
import {
buildWindowsEnvironmentBlock,
quoteWindowsArgumentLikeLibuv,
setIsolatedSpawner,
spawnDetachedWithoutInheritance,
type IsolatedSpawner
} from './isolated-detached-spawn'
const originalPlatform = process.platform
function onPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', { value: platform })
}
describe('quoteWindowsArgumentLikeLibuv', () => {
// The expected outputs are the ones libuv's quote_cmd_arg documents.
it.each([
['', '""'],
['hello', 'hello'],
['hello\\world', 'hello\\world'],
['hello world', '"hello world"'],
['hello"world', '"hello\\"world"'],
['hello""world', '"hello\\"\\"world"'],
['hello\\"world', '"hello\\\\\\"world"'],
['hello\\\\"world', '"hello\\\\\\\\\\"world"'],
['hello world\\', '"hello world\\\\"'],
['C:\\Program Files\\x', '"C:\\Program Files\\x"']
])('quotes %j as %s', (value, expected) => {
expect(quoteWindowsArgumentLikeLibuv(value)).toBe(expected)
})
})
describe('buildWindowsEnvironmentBlock', () => {
it('sorts case-insensitively, keeps the first of a duplicate, and ends with two NULs', () => {
const block = buildWindowsEnvironmentBlock(
{ b: '2', A: '1', B: 'dup', SYSTEMROOT: 'C:\\Windows', skip: undefined },
{}
)
expect(block).toBe('A=1\0b=2\0SYSTEMROOT=C:\\Windows\0\0')
})
it('fills required variables from the parent the way libuv does', () => {
const block = buildWindowsEnvironmentBlock({ X: '1' }, { SystemRoot: 'C:\\W', Other: 'no' })
expect(block).toBe('SystemRoot=C:\\W\0X=1\0\0')
})
it('keeps an empty environment a valid block', () => {
expect(buildWindowsEnvironmentBlock({}, {})).toBe('\0')
})
})
describe('spawnDetachedWithoutInheritance', () => {
afterEach(() => {
setIsolatedSpawner(null)
onPlatform(originalPlatform)
vi.unstubAllEnvs()
spawnProcess.mockReset()
})
it('uses a detached, ignoring libuv spawn off Windows', () => {
onPlatform('linux')
const spawner = vi.fn<IsolatedSpawner>()
setIsolatedSpawner(spawner)
spawnProcess.mockReturnValue({ pid: 1 })
spawnDetachedWithoutInheritance({ program: '/bin/true', args: ['x'], cwd: '/' })
expect(spawner).not.toHaveBeenCalled()
expect(spawnProcess).toHaveBeenCalledWith({
program: '/bin/true',
args: ['x'],
cwd: '/',
detached: true,
stdio: 'ignore'
})
})
it('falls back to libuv on Windows when no spawner is installed', () => {
onPlatform('win32')
spawnProcess.mockReturnValue({ pid: 1 })
spawnDetachedWithoutInheritance({ program: 'C:\\Windows\\System32\\wsl.exe' })
expect(spawnProcess).toHaveBeenCalledOnce()
})
it.each(['C:\\tools\\run.cmd', 'wsl.exe'])('falls back to libuv for %s', (program) => {
onPlatform('win32')
const spawner = vi.fn<IsolatedSpawner>()
setIsolatedSpawner(spawner)
spawnProcess.mockReturnValue({ pid: 1 })
spawnDetachedWithoutInheritance({ program, env: { ComSpec: 'cmd.exe' } })
expect(spawner).not.toHaveBeenCalled()
expect(spawnProcess).toHaveBeenCalledOnce()
})
it('hands the native spawner a libuv-shaped command line and environment block', async () => {
onPlatform('win32')
let exit: (code: number) => void = () => {}
const spawner = vi.fn<IsolatedSpawner>((_file, _line, _cwd, _env, onExit) => {
exit = onExit
return { pid: 42, exitWatchError: null }
})
setIsolatedSpawner(spawner)
const child = spawnDetachedWithoutInheritance({
program: 'C:\\Windows\\System32\\wsl.exe',
args: ['--exec', '/bin/sh', '-c', 'echo "hi"'],
cwd: 'C:\\Users\\me',
env: { WSL_UTF8: '1' }
})
await once(child, 'spawn')
const exited = once(child, 'exit')
exit(3)
expect(child.pid).toBe(42)
expect(await exited).toEqual([3, null])
expect(spawner).toHaveBeenCalledWith(
'C:\\Windows\\System32\\wsl.exe',
'C:\\Windows\\System32\\wsl.exe --exec /bin/sh -c "echo \\"hi\\""',
'C:\\Users\\me',
expect.stringMatching(/WSL_UTF8=1\0/),
expect.any(Function)
)
expect(spawnProcess).not.toHaveBeenCalled()
})
it('reports exits like libuv: its own kill as a signal, any other exit as the code', async () => {
onPlatform('win32')
const exits: ((code: number) => void)[] = []
let nextPid = 100
setIsolatedSpawner((_file, _line, _cwd, _env, onExit) => {
exits.push(onExit)
return { pid: nextPid++, exitWatchError: null }
})
const processKill = vi.spyOn(process, 'kill').mockImplementation(() => true)
const stopped = spawnDetachedWithoutInheritance({ program: 'C:\\wsl.exe' })
const killed = spawnDetachedWithoutInheritance({ program: 'C:\\wsl.exe' })
expect(killed.kill()).toBe(true)
expect(processKill).toHaveBeenCalledWith(101, 'SIGTERM')
const stoppedExit = once(stopped, 'exit')
const killedExit = once(killed, 'exit')
// The holder exits 1 when its distro stops; TerminateProcess also leaves 1.
exits[0](1)
exits[1](1)
expect(await stoppedExit).toEqual([1, null])
expect(await killedExit).toEqual([null, 'SIGTERM'])
expect(killed.kill()).toBe(false)
})
it('reports a native spawn failure as an error event, like libuv', async () => {
onPlatform('win32')
setIsolatedSpawner(() => {
throw new Error('CreateProcessW failed with Win32 error 2')
})
const child = spawnDetachedWithoutInheritance({ program: 'C:\\missing.exe' })
const [error] = await once(child, 'error')
expect(child.pid).toBeUndefined()
expect(String(error)).toContain('Win32 error 2')
})
it('rejects a WSL distro outside the allowlist before spawning', () => {
onPlatform('win32')
vi.stubEnv('ORCA_INTERNAL_WSL_DISTRO_ALLOWLIST', 'allowed-distro')
const spawner = vi.fn<IsolatedSpawner>()
setIsolatedSpawner(spawner)
expect(() =>
spawnDetachedWithoutInheritance({
program: 'C:\\Windows\\System32\\wsl.exe',
args: ['-d', 'not-allowed-distro', '--exec', 'true']
})
).toThrow()
expect(spawner).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,157 @@
import { EventEmitter } from 'node:events'
import { win32 } from 'node:path'
import type { ProcessSpec } from './process-spec'
import { spawnProcess } from './run-process'
import { resolveSpawn } from './spawn-resolution'
import { hasSpawnObserver, notifySpawnObserver } from './spawn-observer'
/** Main installs the native spawner; daemon, relay, and CLI fall back to libuv. Empty cwd: ours. */
export type IsolatedSpawner = (
file: string,
commandLine: string,
cwd: string,
environmentBlock: string,
onExit: (code: number) => void
) => { pid: number; exitWatchError: number | null }
export type DetachedChild = EventEmitter & {
readonly pid?: number
unref(): void
kill(signal?: NodeJS.Signals): boolean
}
export type DetachedSpec = Omit<ProcessSpec, 'stdio' | 'detached' | 'windowsVerbatimArguments'>
let isolatedSpawner: IsolatedSpawner | null = null
export function setIsolatedSpawner(next: IsolatedSpawner | null): void {
isolatedSpawner = next
}
// libuv's quote_cmd_arg, so a holder's command line matches what uv_spawn produced before.
export function quoteWindowsArgumentLikeLibuv(value: string): string {
if (value === '') {
return '""'
}
if (!/[ \t"]/.test(value)) {
return value
}
if (!/["\\]/.test(value)) {
return `"${value}"`
}
let quoted = '"'
let backslashes = 0
for (const char of value) {
if (char === '\\') {
backslashes += 1
continue
}
quoted +=
char === '"' ? `${'\\'.repeat(backslashes * 2 + 1)}"` : `${'\\'.repeat(backslashes)}${char}`
backslashes = 0
}
return `${quoted}${'\\'.repeat(backslashes * 2)}"`
}
// Variables libuv copies from the parent when the given env lacks them; Windows breaks without some.
const REQUIRED_WINDOWS_ENV = [
'HOMEDRIVE',
'HOMEPATH',
'LOGONSERVER',
'PATH',
'SYSTEMDRIVE',
'SYSTEMROOT',
'TEMP',
'USERDOMAIN',
'USERNAME',
'USERPROFILE',
'WINDIR'
]
/** libuv's make_program_env: required variables filled in, sorted case-insensitively, `\0\0`-ended. */
export function buildWindowsEnvironmentBlock(
env: NodeJS.ProcessEnv,
parentEnv: NodeJS.ProcessEnv = process.env
): string {
const entries = new Map<string, string>()
for (const [key, value] of Object.entries(env)) {
if (value !== undefined && !entries.has(key.toUpperCase())) {
entries.set(key.toUpperCase(), `${key}=${value}`)
}
}
for (const required of REQUIRED_WINDOWS_ENV) {
if (entries.has(required)) {
continue
}
const inherited = Object.entries(parentEnv).find(([key]) => key.toUpperCase() === required)
if (inherited?.[1] !== undefined) {
entries.set(required, `${inherited[0]}=${inherited[1]}`)
}
}
const sorted = [...entries.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
return `${sorted.map(([, entry]) => `${entry}\0`).join('')}\0`
}
/**
* Detached, stdio-ignoring spawn whose child inherits none of Orca's handles on Windows.
* Falls back to spawnProcess off Windows, without an installed spawner, or for programs that need
* cmd.exe or a relative-path lookup.
*/
export function spawnDetachedWithoutInheritance(spec: DetachedSpec): DetachedChild {
const fallback = (): DetachedChild => spawnProcess({ ...spec, detached: true, stdio: 'ignore' })
const spawner = isolatedSpawner
if (process.platform !== 'win32' || !spawner) {
return fallback()
}
const resolved = resolveSpawn({ ...spec, detached: true, stdio: 'ignore' }, 'win32')
if (resolved.options.windowsVerbatimArguments || !win32.isAbsolute(resolved.file)) {
return fallback()
}
const child = new EventEmitter()
const commandLine = [resolved.file, ...resolved.args].map(quoteWindowsArgumentLikeLibuv).join(' ')
const startedAt = hasSpawnObserver() ? performance.now() : null
let pid: number | undefined
let exited = false
let killedWith: NodeJS.Signals | null = null
// Like libuv: an exit after our own kill() reports (null, signal); any other exit, its code.
const onExit = (code: number): void => {
exited = true
child.emit('exit', killedWith ? null : code, killedWith)
child.emit('close', killedWith ? null : code, killedWith)
}
const kill = (signal: NodeJS.Signals = 'SIGTERM'): boolean => {
if (pid === undefined || exited) {
return false
}
try {
process.kill(pid, signal)
} catch {
return false
}
killedWith = signal
return true
}
try {
const spawned = spawner(
resolved.file,
commandLine,
spec.cwd ?? '',
buildWindowsEnvironmentBlock(resolved.options.env ?? process.env),
onExit
)
pid = spawned.pid
if (startedAt !== null) {
notifySpawnObserver(resolved.file, resolved.args, performance.now() - startedAt)
}
if (spawned.exitWatchError !== null) {
console.warn(
`[process-host] pid ${pid} exit is unobservable; Win32 error ${spawned.exitWatchError}`
)
}
process.nextTick(() => child.emit('spawn'))
} catch (error) {
process.nextTick(() => child.emit('error', error))
}
// The native exit watch never holds the event loop, so unref has nothing to release.
return Object.assign(child, { pid, unref: () => {}, kill })
}
@@ -107,6 +107,10 @@ export function createFakeWindowsSecurity(): FakeWindowsSecurity {
fake.calls.filter((call) => call.op === op && (path === undefined || call.path === path)),
currentUserSid: () => FAKE_USER_SID,
clearStdHandleInheritance: () => [],
spawnDetachedIsolated: () => {
throw new Error('spawnDetachedIsolated is not faked')
},
hasNativeExport: () => true,
getDaclAces: (addonPath) => {
const path = record('get', addonPath)
return daclFromSddl(fake.dacls.get(path) ?? INHERITED_SDDL)
+4 -1
View File
@@ -111,7 +111,10 @@ function prepare(raw: WindowsSecurityAddon): WindowsSecurity {
raw.grantAccess(toNamespacedPath(path), sid, mask, options),
grantAccessAsync: (path, sid, mask, options) =>
inPathOrder(path, (namespaced) => raw.grantAccessAsync(namespaced, sid, mask, options)),
clearStdHandleInheritance: () => raw.clearStdHandleInheritance()
clearStdHandleInheritance: () => raw.clearStdHandleInheritance(),
spawnDetachedIsolated: (file, commandLine, cwd, environmentBlock, onExit) =>
raw.spawnDetachedIsolated(file, commandLine, cwd, environmentBlock, onExit),
hasNativeExport: (name) => raw.hasNativeExport(name)
}
}
}