fix(windows): preserve caller affinity only within one processor group (#2202)

* fix(windows): preserve caller affinity only within one processor group

`spawnProcess` read the caller's affinity mask and applied it to the
suspended child unconditionally. A mask names processors relative to one
processor group, and Windows assigns each new process to a group of its
own choosing, so on hosts with more than 64 logical processors the child
is often placed in another group. `SetProcessAffinityMask` then rejects
the mask with ERROR_INVALID_PARAMETER (87) and the spawn fails, making
every owned provider process start intermittently on such hosts.

Affinity is a nicety, not an authority or isolation control, so it is now
applied only when the caller and the suspended child both occupy the same
single processor group. A child placed in another group keeps that group's
default affinity and the spawn proceeds. Job Object membership,
kill-on-owner-close policy, owner normalization and priority preservation
are unchanged, and the mask failure remains fatal within a shared group,
where it is not expected.

Single-group hosts, including every host with 64 or fewer logical
processors, are unaffected: `GetProcessGroupAffinity` reports one group
for both processes and the same mask is applied as before.

The optional process-boundary fixture gains an `affinity` observation,
and the native verification lane asserts that an owned spawn succeeds and
that the child's mask matches the caller's whenever both share a single
group.

Fixes #2181

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(windows): refuse to widen a pinned caller's affinity across groups

Addresses review feedback on the first commit.

An unconstrained caller loses nothing when Windows places an owned child
in another processor group, so that spawn proceeds with the group's
default. A caller deliberately pinned to fewer processors than its group
holds is a different case: silently accepting the default would hand the
provider tree the whole group, which `docs/installation.md` tells
operators to rely on affinity to prevent. Such a spawn now fails naming
the constraint instead. `GetProcessAffinityMask` already reports the
group's active set, which distinguishes the two without another query.

Selecting the caller's group at creation through
PROC_THREAD_ATTRIBUTE_GROUP_AFFINITY was tried first and rejected:
`CreateProcessW` fails with ERROR_INVALID_PARAMETER on a host with a
single processor group, both alongside PROC_THREAD_ATTRIBUTE_JOB_LIST and
on its own, so it cannot carry the ordinary path.

The boundary fixture's affinity observation now reports a process id's
own mask rather than its own, because a separate probe process is
assigned its own group and cannot stand in for the process that spawns
the child. Masks and the group's active set cross as fixed-width
hexadecimal strings: a mask can set bits above the safe-integer range,
where `JSON.parse` would round two different masks to one value.

The verification lane asserts the mask is inherited exactly when caller
and child share a group, and otherwise that the caller was unconstrained.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(windows): record what owned-process affinity does not preserve

A mask covering its group's whole active set does not distinguish a
caller the system placed in that group from one that selected the group
deliberately, so an owned child can still be placed elsewhere with its
processor count bounded and its group placement lost. Treating that case
as constrained instead is not available: on a multi-group host an
ordinary process is assigned a group by the system and its mask is that
group's active set, so refusing it would fail every owned spawn there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Stefano Rizzello 2026-10-13 08:49:34 +02:00 committed by GitHub
parent 2c6a4fac4f
commit ab62ee9dcf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 123 additions and 7 deletions

View File

@ -153,8 +153,12 @@ the private runtime, isolating Ghidra preferences, logs, and caches.
Process creation assigns a suspended child atomically to an owned Job Object. Process creation assigns a suspended child atomically to an owned Job Object.
Membership and kill-on-owner-close policy are verified before resume. Membership and kill-on-owner-close policy are verified before resume.
Breakaway is disabled. Only the child's default file owner is normalized; Breakaway is disabled. Only the child's default file owner is normalized;
the caller token stays unchanged. Caller CPU affinity and priority are the caller token stays unchanged. Caller priority is preserved, and caller CPU
preserved. Close, cancellation, timeout, startup failure, and provider failure affinity whenever the caller and the child share a processor group. A child that
Windows places in another group keeps that group's default when the caller asked
for no bound of its own; a caller pinned to fewer processors than its group
holds is refused there rather than widened.
Close, cancellation, timeout, startup failure, and provider failure
terminate the job and verify all members have settled before runtime cleanup. terminate the job and verify all members have settled before runtime cleanup.
Cleanup removes admitted objects by handle and unlinks reparse entries without Cleanup removes admitted objects by handle and unlinks reparse entries without
following their targets. Abrupt owner death kills the job but can leave following their targets. Abrupt owner death kills the job but can leave

View File

@ -69,10 +69,22 @@ without this lane reports Windows native controls as unavailable.
limits only for its JavaScript helpers when running on Bun, which ignores limits only for its JavaScript helpers when running on Bun, which ignores
their V8 heap flags; Node.js launches are unchanged. their V8 heap flags; Node.js launches are unchanged.
- Only the suspended child's default file owner is normalized. Caller token - Only the suspended child's default file owner is normalized. Caller token
privileges and ownership remain unchanged. Caller CPU affinity and process privileges and ownership remain unchanged. Caller process priority is
priority are preserved in the child. Environment names are sorted and preserved in the child, and caller CPU affinity whenever both occupy the same
deduplicated using Windows ordinal comparison, preserving their native Unicode processor group. Windows chooses a new process's group itself and rejects a
semantics and caller-selected values. mask belonging to another group, so a child placed elsewhere keeps that
group's default when the caller asked for no bound of its own. A caller
deliberately pinned to fewer processors than its group holds is a constraint
such a child cannot express, and is refused rather than widened to that
group's default. A mask covering its group's whole active set does not
distinguish a caller Windows placed in that group from one that selected the
group deliberately, so such a child can still be placed elsewhere: its
processor count stays bounded, its group placement is not preserved, and
neither that case nor the refusal above has a verification lane, because both
need a host whose processors span more than one group.
Environment names are sorted and deduplicated using Windows
ordinal comparison, preserving their native Unicode semantics and
caller-selected values.
- Cleanup walks locked directory objects, removes files by handle, and unlinks - Cleanup walks locked directory objects, removes files by handle, and unlinks
reparse entries without traversing their targets. Failed removal retains the reparse entries without traversing their targets. Failed removal retains the
root and ancestor leases for retry; a partially removed runtime accepts only root and ancestor leases for retry; a partially removed runtime accepts only

View File

@ -68,6 +68,18 @@ static ULONGLONG jobMemoryLimit(napi_env env, napi_value value) {
return limit; return limit;
} }
// An affinity mask is meaningful only inside one processor group, so only a
// process occupying exactly one group has a group a child can inherit. A
// process whose threads span groups, or whose membership cannot be read, has no
// single group.
static bool singleProcessorGroup(HANDLE process, USHORT& group) {
USHORT count = 1;
std::array<USHORT, 1> groups{};
if (!GetProcessGroupAffinity(process, &count, groups.data()) || count != 1) return false;
group = groups[0];
return true;
}
static void pipe(Handle& read, Handle& write, bool parentReads = true) { static void pipe(Handle& read, Handle& write, bool parentReads = true) {
SECURITY_ATTRIBUTES attributes{sizeof(SECURITY_ATTRIBUTES), nullptr, TRUE}; SECURITY_ATTRIBUTES attributes{sizeof(SECURITY_ATTRIBUTES), nullptr, TRUE};
HANDLE rawRead, rawWrite; HANDLE rawRead, rawWrite;
@ -168,11 +180,27 @@ std::unique_ptr<Process> spawnProcess(const std::wstring& command, const std::ws
TOKEN_OWNER owner{reinterpret_cast<TOKEN_USER*>(tokenUser.data())->User.Sid}; TOKEN_OWNER owner{reinterpret_cast<TOKEN_USER*>(tokenUser.data())->User.Sid};
require(SetTokenInformation(token.get(), TokenOwner, &owner, sizeof(owner)), require(SetTokenInformation(token.get(), TokenOwner, &owner, sizeof(owner)),
"Set suspended process file owner failed", command); "Set suspended process file owner failed", command);
// A mask names processors inside one processor group, and Windows assigns a
// new process to a group of its own choosing, so a mask belonging to another
// group is rejected (ERROR_INVALID_PARAMETER). An unconstrained caller, whose
// mask is its group's whole active set, asks for no bound the child's own
// group cannot give, so such a child keeps that group's default and the spawn
// proceeds. A narrower mask is a deliberate CPU constraint that a child in
// another group cannot express, and silently widening it would hand the
// provider tree the whole group, so that spawn fails naming the constraint.
DWORD_PTR affinity = 0, systemAffinity = 0; DWORD_PTR affinity = 0, systemAffinity = 0;
require(GetProcessAffinityMask(GetCurrentProcess(), &affinity, &systemAffinity), require(GetProcessAffinityMask(GetCurrentProcess(), &affinity, &systemAffinity),
"Read caller process affinity failed", command); "Read caller process affinity failed", command);
if (affinity != 0) USHORT callerGroup = 0, childGroup = 0;
const bool sharedGroup = singleProcessorGroup(GetCurrentProcess(), callerGroup) &&
singleProcessorGroup(result->process.get(), childGroup) &&
callerGroup == childGroup;
if (affinity != 0 && sharedGroup)
require(SetProcessAffinityMask(result->process.get(), affinity), "Preserve caller process affinity failed", command); require(SetProcessAffinityMask(result->process.get(), affinity), "Preserve caller process affinity failed", command);
else
require(affinity == 0 || affinity == systemAffinity,
"Caller CPU affinity cannot bound an owned process in another processor group", command,
ERROR_INVALID_PARAMETER);
const DWORD priority = GetPriorityClass(GetCurrentProcess()); const DWORD priority = GetPriorityClass(GetCurrentProcess());
require(priority != 0 && SetPriorityClass(result->process.get(), priority), require(priority != 0 && SetPriorityClass(result->process.get(), priority),
"Preserve caller process priority failed", command); "Preserve caller process priority failed", command);

View File

@ -662,6 +662,42 @@ setInterval(()=>{},1000);\n`,
unicodeNamesPreserved: true, unicodeNamesPreserved: true,
}); });
report.processes.windowsEnvironmentSemantics = true; report.processes.windowsEnvironmentSemantics = true;
// Observed through this process's own id: a separate probe process would
// get its own processor group, so it cannot stand in for the process that
// spawns the child.
const callerAffinity = JSON.parse(
execFileSync(boundaryFixture, ["affinity", String(process.pid)], {
encoding: "utf8",
}),
);
const affinityChild = native.call("process_spawn", [
boundaryFixture,
`${quote(boundaryFixture)} affinity`,
workspace,
environment,
]);
const observedAffinity = await wait(affinityChild);
native.call("process_close", [affinityChild.handle]);
assert.equal(observedAffinity.exitCode, 0);
const childAffinity = JSON.parse(observedAffinity.output);
// Masks cross as hexadecimal strings because a mask can exceed the
// safe-integer range. Sharing one processor group makes the caller's mask
// applicable, so it must be inherited exactly. Windows placing the child in
// another group is only permitted when the caller asked for no bound of its
// own, which its group's whole active set establishes; a narrower mask has
// to fail the spawn rather than widen to that group's default.
const sharedGroup =
callerAffinity.singleProcessorGroup &&
childAffinity.singleProcessorGroup &&
callerAffinity.processorGroup === childAffinity.processorGroup;
if (sharedGroup)
assert.equal(childAffinity.affinityMask, callerAffinity.affinityMask);
else assert.equal(callerAffinity.affinityMask, callerAffinity.systemMask);
report.processes.callerAffinityBoundsOwnedProcessOrRefusesToWiden = true;
report.callerAffinity = {
...callerAffinity,
sharedGroupWithChild: sharedGroup,
};
} }
const ownerFixture = join(workspace, "owner.mjs"); const ownerFixture = join(workspace, "owner.mjs");
const ownerPidFile = join(workspace, "crash-child.pid"); const ownerPidFile = join(workspace, "crash-child.pid");

View File

@ -4,6 +4,7 @@
#include <winioctl.h> #include <winioctl.h>
#include <cstdio> #include <cstdio>
#include <cstring> #include <cstring>
#include <cwchar>
#include <string> #include <string>
#include <vector> #include <vector>
@ -33,6 +34,41 @@ int wmain(int count, wchar_t** args) {
value(L"REA_\u00df") == expectedSharp && value(L"REA_SS") == L"double" ? "true" : "false"); value(L"REA_\u00df") == expectedSharp && value(L"REA_SS") == L"double" ? "true" : "false");
return 0; return 0;
} }
if ((count == 2 || count == 3) && std::wstring(args[1]) == L"affinity") {
// Without a process id this observes itself, which is what an owned child
// reports. With one it observes that process, so a caller's own affinity is
// read from the process that spawns the child rather than from a separate
// probe Windows may place in another processor group.
HANDLE opened = nullptr;
if (count == 3) {
opened = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE,
static_cast<DWORD>(std::wcstoul(args[2], nullptr, 10)));
if (!opened) return 9;
}
const HANDLE target = opened ? opened : GetCurrentProcess();
DWORD_PTR affinity = 0, systemAffinity = 0;
USHORT groupCount = 1;
USHORT groups[1]{};
const bool read = GetProcessAffinityMask(target, &affinity, &systemAffinity) != 0;
const bool single = read && GetProcessGroupAffinity(target, &groupCount, groups) && groupCount == 1;
if (opened) CloseHandle(opened);
if (!read) return 8;
// A mask can set bits above JavaScript's safe-integer range, so masks cross
// as fixed-width hexadecimal strings and are compared without rounding. The
// group's own active set distinguishes an unconstrained process from one
// deliberately pinned to fewer processors.
auto mask = [](DWORD_PTR value, char* text) {
std::snprintf(text, 19, "0x%08lX%08lX", static_cast<unsigned long>(value >> 32),
static_cast<unsigned long>(value & 0xFFFFFFFFu));
return text;
};
char affinityText[19]{}, systemText[19]{};
std::printf("{\"affinityMask\":\"%s\",\"systemMask\":\"%s\","
"\"singleProcessorGroup\":%s,\"processorGroup\":%u}\n",
mask(affinity, affinityText), mask(systemAffinity, systemText),
single ? "true" : "false", single ? static_cast<unsigned>(groups[0]) : 0u);
return 0;
}
if (count == 4 && std::wstring(args[1]) == L"reparse-directory") { if (count == 4 && std::wstring(args[1]) == L"reparse-directory") {
// Only the conformance lane's disposable, nonempty source directory is used. // Only the conformance lane's disposable, nonempty source directory is used.
const auto substitute = L"\\??\\" + std::wstring(args[3]); const auto substitute = L"\\??\\" + std::wstring(args[3]);