mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Vendored
+20
@@ -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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user