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:
parent
2c6a4fac4f
commit
ab62ee9dcf
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue