win-nice 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +141 -0
- package/README.md +309 -41
- package/bin/abovenormal +11 -11
- package/bin/abovenormal.bat +2 -2
- package/bin/abovenormal.ps1 +38 -10
- package/bin/admin +11 -11
- package/bin/admin.bat +2 -2
- package/bin/admin.ps1 +52 -13
- package/bin/belownormal +11 -11
- package/bin/belownormal.bat +2 -2
- package/bin/belownormal.ps1 +38 -10
- package/bin/{cap → capc} +11 -11
- package/bin/{cap.bat → capc.bat} +3 -3
- package/bin/capc.ps1 +432 -0
- package/bin/{pint → capm} +11 -11
- package/bin/capm.bat +12 -0
- package/bin/capm.ps1 +506 -0
- package/bin/capn +11 -0
- package/bin/capn.bat +8 -0
- package/bin/capn.ps1 +426 -0
- package/bin/caps +11 -0
- package/bin/caps.bat +10 -0
- package/bin/caps.ps1 +589 -0
- package/bin/capt +11 -0
- package/bin/capt.bat +8 -0
- package/bin/capt.ps1 +449 -0
- package/bin/cx +11 -11
- package/bin/cx.bat +1 -1
- package/bin/cx.ps1 +38 -10
- package/bin/cy +11 -11
- package/bin/cy.bat +1 -1
- package/bin/cy.ps1 +38 -10
- package/bin/high +11 -11
- package/bin/high.bat +2 -2
- package/bin/high.ps1 +38 -10
- package/bin/idle +11 -11
- package/bin/idle.bat +2 -2
- package/bin/idle.ps1 +38 -10
- package/bin/realtime +11 -11
- package/bin/realtime.bat +2 -2
- package/bin/realtime.ps1 +38 -10
- package/bin/uiup +11 -11
- package/bin/uiup.bat +1 -1
- package/bin/uiup.ps1 +2 -1
- package/install/install.js +30 -15
- package/install/paths.js +28 -2
- package/install/skill.js +25 -1
- package/install/uninstall.js +11 -0
- package/package.json +7 -3
- package/skills/win-nice/SKILL.md +107 -12
- package/bin/cap.ps1 +0 -269
- package/bin/pint.bat +0 -8
- package/bin/pint.ps1 +0 -270
package/install/paths.js
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
+
const fs = require('fs');
|
|
2
3
|
const os = require('os');
|
|
3
4
|
const path = require('path');
|
|
4
5
|
const { execFileSync } = require('child_process');
|
|
5
6
|
|
|
7
|
+
// Guards mutating commands (install/uninstall/reinstall) against touching the
|
|
8
|
+
// real system PATH/install dir when run from inside a git clone - only a
|
|
9
|
+
// genuine package install (running from inside someone's node_modules) or an
|
|
10
|
+
// explicit WIN_NICE_HOME override proceeds.
|
|
11
|
+
function isSourceCheckout() {
|
|
12
|
+
return fs.existsSync(path.join(__dirname, '..', '.git'));
|
|
13
|
+
}
|
|
14
|
+
|
|
6
15
|
// WIN_NICE_HOME overrides the install root - used by tests and by anyone who
|
|
7
16
|
// wants a non-default location. Real installs default to %LOCALAPPDATA%\win-nice.
|
|
8
17
|
function installRoot() {
|
|
@@ -57,10 +66,25 @@ function removeFromPathString(currentPath, dir) {
|
|
|
57
66
|
return parts.filter((p) => comparisonForm(p) !== target).join(';');
|
|
58
67
|
}
|
|
59
68
|
|
|
69
|
+
// Windows PowerShell is a fixed system dependency. Passing only its bare
|
|
70
|
+
// name to CreateProcess lets libuv search the current directory before PATH,
|
|
71
|
+
// so a powershell.exe planted next to `npm install` could run during the
|
|
72
|
+
// installer's registry update. Keep the path construction injectable through
|
|
73
|
+
// the env argument for unit tests, but never resolve the Windows executable
|
|
74
|
+
// through PATH in production.
|
|
75
|
+
function powershellPath(env = process.env) {
|
|
76
|
+
const systemRoot = env.SystemRoot || env.WINDIR;
|
|
77
|
+
if (!systemRoot || !path.win32.isAbsolute(systemRoot)) {
|
|
78
|
+
throw new Error('SystemRoot must be an absolute Windows path');
|
|
79
|
+
}
|
|
80
|
+
return path.win32.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
|
|
81
|
+
}
|
|
82
|
+
|
|
60
83
|
function runPowershell(script, extraEnv) {
|
|
61
|
-
|
|
84
|
+
const env = extraEnv ? { ...process.env, ...extraEnv } : process.env;
|
|
85
|
+
return execFileSync(powershellPath(env), ['-NoProfile', '-Command', script], {
|
|
62
86
|
encoding: 'utf8',
|
|
63
|
-
env
|
|
87
|
+
env,
|
|
64
88
|
});
|
|
65
89
|
}
|
|
66
90
|
|
|
@@ -129,11 +153,13 @@ function writeUserPath(newPath) {
|
|
|
129
153
|
}
|
|
130
154
|
|
|
131
155
|
module.exports = {
|
|
156
|
+
isSourceCheckout,
|
|
132
157
|
installRoot,
|
|
133
158
|
binDir,
|
|
134
159
|
manifestPath,
|
|
135
160
|
addToPathString,
|
|
136
161
|
removeFromPathString,
|
|
162
|
+
powershellPath,
|
|
137
163
|
readUserPath,
|
|
138
164
|
writeUserPath,
|
|
139
165
|
readRegistryString,
|
package/install/skill.js
CHANGED
|
@@ -45,6 +45,30 @@ function installSkill() {
|
|
|
45
45
|
return results;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
// Called from install() (postinstall / `win-nice install|reinstall`), not just
|
|
49
|
+
// the explicit opt-in `win-nice skill install` - a package upgrade must not
|
|
50
|
+
// leave a previously-installed skill copy silently stale (e.g. recommending
|
|
51
|
+
// tool names a breaking rename just deleted). Only refreshes copies that are
|
|
52
|
+
// ALREADY there and still carry the marker: never creates one for a user who
|
|
53
|
+
// never opted in, and never touches a missing or foreign/unmarked file.
|
|
54
|
+
function updateInstalledSkill() {
|
|
55
|
+
const content = fs.readFileSync(SOURCE, 'utf8');
|
|
56
|
+
const results = [];
|
|
57
|
+
for (const target of targets()) {
|
|
58
|
+
if (!fs.existsSync(target)) {
|
|
59
|
+
results.push({ file: target, updated: false, reason: 'not installed' });
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (!fs.readFileSync(target, 'utf8').includes(MARKER)) {
|
|
63
|
+
results.push({ file: target, updated: false, reason: 'marker missing (modified by user?)' });
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
fs.writeFileSync(target, content);
|
|
67
|
+
results.push({ file: target, updated: true });
|
|
68
|
+
}
|
|
69
|
+
return results;
|
|
70
|
+
}
|
|
71
|
+
|
|
48
72
|
function uninstallSkill() {
|
|
49
73
|
const results = [];
|
|
50
74
|
for (const target of targets()) {
|
|
@@ -65,4 +89,4 @@ function uninstallSkill() {
|
|
|
65
89
|
return results;
|
|
66
90
|
}
|
|
67
91
|
|
|
68
|
-
module.exports = { installSkill, uninstallSkill, targets, MARKER };
|
|
92
|
+
module.exports = { installSkill, uninstallSkill, updateInstalledSkill, targets, MARKER };
|
package/install/uninstall.js
CHANGED
|
@@ -25,6 +25,17 @@ function removeManagedFile(filePath, expectedDir, { requireMarker }) {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
function uninstall({ updatePath = true } = {}) {
|
|
28
|
+
// Mirrors install()'s own guard: without it, `win-nice reinstall`/`uninstall`
|
|
29
|
+
// run from inside a git clone (no WIN_NICE_HOME) would delete a real prior
|
|
30
|
+
// install and PATH entry, then (for reinstall) silently skip reinstalling it.
|
|
31
|
+
if (!process.env.WIN_NICE_HOME && paths.isSourceCheckout()) {
|
|
32
|
+
console.log(
|
|
33
|
+
'Running from a source checkout - skipping real uninstall. ' +
|
|
34
|
+
'Set WIN_NICE_HOME to force a target directory, or uninstall the published package.'
|
|
35
|
+
);
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
|
|
28
39
|
const dir = paths.binDir();
|
|
29
40
|
const manifestFile = paths.manifestPath();
|
|
30
41
|
const data = manifest.read(manifestFile);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "win-nice",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Windows nice/renice/cpulimit for parallel AI coding agents - process priority (idle/belownormal/abovenormal/high/realtime), hard CPU quotas
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Windows nice/renice/cpulimit for parallel AI coding agents - process priority (idle/belownormal/abovenormal/high/realtime), hard CPU quotas, thread affinity, memory limits, and process-count ceilings via Job Objects (capc/capt/capm/capn), a wall-clock timeout that kills the whole tree on expiry (caps), elevation (admin), a UI responsiveness boost, and an optional Claude Code/Codex reference skill, with no dependencies.",
|
|
5
5
|
"license": "MIT OR Apache-2.0",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Marat K",
|
|
@@ -26,17 +26,21 @@
|
|
|
26
26
|
"install",
|
|
27
27
|
"skills",
|
|
28
28
|
"README.md",
|
|
29
|
+
"CHANGELOG.md",
|
|
29
30
|
"LICENSE-MIT",
|
|
30
31
|
"LICENSE-APACHE"
|
|
31
32
|
],
|
|
32
33
|
"scripts": {
|
|
33
34
|
"postinstall": "node install/cli.js install",
|
|
34
|
-
"test": "node --test test/cli.test.js test/docs-sync.test.js test/gitbash-shims.test.js test/install-uninstall.test.js test/manifest.test.js test/paths.test.js test/skill.test.js"
|
|
35
|
+
"test": "node --test test/cli.test.js test/docs-sync.test.js test/gitbash-shims.test.js test/install-uninstall.test.js test/manifest.test.js test/paths.test.js test/release-check-allowlist.test.js test/skill.test.js",
|
|
36
|
+
"test:elevated": "\"%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe\" -NoProfile -ExecutionPolicy Bypass -File test\\run-elevated.ps1",
|
|
37
|
+
"release-check": "node scripts/release-check.js"
|
|
35
38
|
},
|
|
36
39
|
"keywords": [
|
|
37
40
|
"windows",
|
|
38
41
|
"priority",
|
|
39
42
|
"cpu",
|
|
43
|
+
"memory",
|
|
40
44
|
"job-object",
|
|
41
45
|
"nice",
|
|
42
46
|
"cpulimit",
|
package/skills/win-nice/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: win-nice
|
|
3
|
-
description: Reference for win-nice's Windows CLI tools for process priority, hard CPU quotas,
|
|
3
|
+
description: Reference for win-nice's Windows CLI tools for process priority, hard CPU quotas, CPU affinity, memory limits, process-count ceilings, and a wall-clock timeout (idle, belownormal, abovenormal, high, realtime, capc, capt, capm, caps, capn, uiup, admin). Use when the user asks how to limit CPU usage, priority, thread/core affinity, memory, or the number of concurrent processes for a command on Windows, wants to kill a command after a timeout, wants to avoid a build/test freezing the desktop, or mentions any of these tool names.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
<!-- win-nice: managed-skill -->
|
|
@@ -41,39 +41,126 @@ runs at ordinary `Normal` priority. Confirmed empirically, not just from docs.
|
|
|
41
41
|
|
|
42
42
|
Job-Object-based; cover the *whole* process tree from the wrapped command's
|
|
43
43
|
first instruction (created suspended, assigned to the job, only then resumed —
|
|
44
|
-
no race window), including anything it spawns, recursively
|
|
44
|
+
no race window), including anything it spawns, recursively via ordinary
|
|
45
|
+
`CreateProcess` calls. Exceptions: explicit `CREATE_BREAKAWAY_FROM_JOB`, and
|
|
46
|
+
processes brought up through an external broker/service (e.g. WMI's
|
|
47
|
+
`Win32_Process.Create`) that never goes through the tree's own `CreateProcess`.
|
|
45
48
|
|
|
46
|
-
- `
|
|
49
|
+
- `capc <percent 1-100> <command> [args...]` — hard CPU quota
|
|
47
50
|
(`JOBOBJECT_CPU_RATE_CONTROL_INFORMATION`, hard cap). A real ceiling on total
|
|
48
51
|
CPU%, not just scheduling priority — holds even when nothing else is
|
|
49
|
-
contending for CPU. Example: `
|
|
50
|
-
- `
|
|
52
|
+
contending for CPU. Example: `capc 50 npm run build`.
|
|
53
|
+
- `capt <thread-count> <command> [args...]` — short for "cap threads": restricts
|
|
51
54
|
the whole tree to the first N *logical processors* via process affinity
|
|
52
55
|
(`JOB_OBJECT_LIMIT_AFFINITY`). Threads, not physical cores — on
|
|
53
56
|
Hyper-Threading/SMT hardware, N logical processors can be fewer physical
|
|
54
57
|
cores. `<thread-count>` must be between 1 and
|
|
55
|
-
`min([Environment]::ProcessorCount, 63)
|
|
58
|
+
`min([Environment]::ProcessorCount, 63)` — under 32-bit PowerShell the cap
|
|
59
|
+
is additionally 32 (the affinity mask is a pointer-sized `UIntPtr`), and
|
|
60
|
+
counts above it are rejected with a usage error naming 64-bit PowerShell.
|
|
61
|
+
Example: `capt 4 npm run build`.
|
|
62
|
+
- `capm <size> <command> [args...]` — hard memory ceiling
|
|
63
|
+
(`JOB_OBJECT_LIMIT_JOB_MEMORY`), aggregate across the whole tree, not
|
|
64
|
+
per-process. `<size>`: bare integer `1`-`100` = percent of total physical RAM
|
|
65
|
+
(same convention as `capc`'s own `<percent 1-100>`, deliberately no `%`
|
|
66
|
+
character - see Chaining below), or `m`/`M` = megabytes (`512m`), or `g`/`G`
|
|
67
|
+
= gigabytes (`2g`). Unlike `capc`, exceeding it doesn't throttle - it fails
|
|
68
|
+
the allocation (`OutOfMemoryException`/`VirtualAlloc` failure), which
|
|
69
|
+
usually crashes the wrapped program since most don't handle that
|
|
70
|
+
gracefully; set it too low and even the wrapped runtime can fail to start.
|
|
71
|
+
Example: `capm 512m npm run build`.
|
|
72
|
+
- `caps <seconds> <command> [args...]` — wall-clock timeout for the whole
|
|
73
|
+
process tree: if the command is still running when `<seconds>` have passed, one
|
|
74
|
+
`TerminateJobObject` kernel call force-kills everything still in the Job
|
|
75
|
+
Object (the whole subtree, from the first instruction via the same
|
|
76
|
+
suspend-then-assign-then-resume mechanism as `capc`/`capt`/`capm`), and
|
|
77
|
+
`caps` exits with code 124 (unix `timeout(1)` convention). The deadline is
|
|
78
|
+
absolute — computed from `DateTime.UtcNow` and armed into a one-shot
|
|
79
|
+
waitable timer waited on together with the process handle
|
|
80
|
+
(`WaitForMultipleObjects`, with a `GetProcessTimes` check rejecting an exit
|
|
81
|
+
that only won the simultaneous-signal race after the deadline) — so time
|
|
82
|
+
the machine spends asleep/suspended counts against it, and it fires
|
|
83
|
+
immediately on wake if it passed during sleep; the due time is a
|
|
84
|
+
system-clock (FILETIME) value, so a manual or service-driven clock
|
|
85
|
+
adjustment during the wait can shorten or lengthen the actual wait relative
|
|
86
|
+
to `<seconds>`. The job carries
|
|
87
|
+
only `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` — no resource limit — which is also
|
|
88
|
+
the backstop if the `caps` wrapper itself dies non-cooperatively; released
|
|
89
|
+
before a normal inside-the-deadline exit, so a daemon the command legitimately
|
|
90
|
+
left running survives. `<seconds>`:
|
|
91
|
+
positive whole or decimal (`2`, `2.5`), converted to whole milliseconds
|
|
92
|
+
(min 1 ms); max 4294967294 ms (~49.7 days) — a deliberate usage ceiling,
|
|
93
|
+
not a uint32 limit (the deadline is an absolute FILETIME and the wait
|
|
94
|
+
itself is INFINITE) — larger values are a usage error, never silently
|
|
95
|
+
truncated. Finishing in time propagates the exit code like every other launcher.
|
|
96
|
+
Example: `caps 300 npm test`.
|
|
97
|
+
- `capn <count> <command> [args...]` — hard ceiling on the number of
|
|
98
|
+
simultaneously active processes in the whole tree
|
|
99
|
+
(`JOB_OBJECT_LIMIT_ACTIVE_PROCESS`). The count includes the directly
|
|
100
|
+
wrapped process itself (it is assigned to the still-empty job before it can
|
|
101
|
+
spawn anything), so `capn 1 <command>` lets the command run but fails its
|
|
102
|
+
first child-spawn attempt. Exceeding the limit fails only the offending
|
|
103
|
+
spawn attempt - nothing is killed or throttled, and a command within budget
|
|
104
|
+
is unaffected (all confirmed empirically). `<count>`: positive whole
|
|
105
|
+
number, 1 to 4294967295 (the uint32 `ActiveProcessLimit` field's own
|
|
106
|
+
range). Example: `capn 10 npm run build`.
|
|
56
107
|
|
|
57
108
|
**A limit sticks to any daemon the wrapped command leaves running**, for that
|
|
58
|
-
daemon's whole lifetime, not just the one `
|
|
59
|
-
membership is permanent once assigned. Build tools that reuse a background
|
|
109
|
+
daemon's whole lifetime, not just the one `capc`/`capt`/`capm`/`capn` call — Job
|
|
110
|
+
Object membership is permanent once assigned. Build tools that reuse a background
|
|
60
111
|
process to skip cold-start cost (`dotnet build`'s VBCSCompiler/MSBuild node
|
|
61
112
|
reuse, a Gradle daemon, `npm run watch`-style file watchers) can leave a
|
|
62
113
|
*later, uncapped-looking* invocation actually running inside an earlier
|
|
63
|
-
`
|
|
114
|
+
`capc`/`capt` call's job. Escape hatches: `dotnet build
|
|
64
115
|
-p:UseSharedCompilation=false`, `gradle --no-daemon` — or accept the daemon
|
|
65
116
|
stays limited until it's killed.
|
|
66
117
|
|
|
118
|
+
## Chaining
|
|
119
|
+
|
|
120
|
+
These tools can be stacked, e.g. `capm 50 capc 50 idle npm run build`. Bare
|
|
121
|
+
tool names resolve through the same `cmd.exe`/`PATHEXT` fallback as any other
|
|
122
|
+
target, so chaining needs the tools' install directory on `PATH`, and any `%`
|
|
123
|
+
in the command line still trips the fail-closed check. Nested Job Object
|
|
124
|
+
limits do **not** follow one universal "smaller wins" rule: CPU rate
|
|
125
|
+
(`capc`) is relative to its parent job and *multiplies* when nested (`capc 50
|
|
126
|
+
capc 50 ...` ≈ 25% of system CPU, not 50% - see
|
|
127
|
+
[`JOBOBJECT_CPU_RATE_CONTROL_INFORMATION`](https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-jobobject_cpu_rate_control_information)),
|
|
128
|
+
while memory (`capm`) ceilings apply independently to accounting scopes of
|
|
129
|
+
different sizes - a job's committed-memory accounting includes every child
|
|
130
|
+
job's committed memory plus its own process, a child job's accounting
|
|
131
|
+
doesn't see the outer wrapper's process at all - so nested `capm` ceilings
|
|
132
|
+
don't reduce to a simple `min(limit1, limit2)`. Process count (`capn`) limits
|
|
133
|
+
are likewise enforced independently per job - a spawn has to fit under every
|
|
134
|
+
job in the chain at once, and an outer job's count already includes the inner
|
|
135
|
+
wrapper process itself (plus anything it spawns, down to a PowerShell-based
|
|
136
|
+
inner tool's own csc.exe/CVTRES.EXE compiler children), so `capn 1 capn 5 ...`
|
|
137
|
+
fails before the inner limit even matters and the effective budget is not a
|
|
138
|
+
plain `min()` - leave outer headroom for the chain itself (roughly 3 slots
|
|
139
|
+
for a PowerShell-based inner tool). Priority (`idle`/etc.) isn't
|
|
140
|
+
a Job Object limit at all - the last one applied wins. A timeout (`caps`) is
|
|
141
|
+
likewise not a Job Object limit being combined - it's a deadline each `caps`
|
|
142
|
+
wrapper enforces on its own direct child: the innermost `caps` fires at its own
|
|
143
|
+
deadline (the outer propagates the 124), while an outer `caps` whose deadline
|
|
144
|
+
fires first kills the whole subtree including the inner wrapper, whose own
|
|
145
|
+
`KILL_ON_JOB_CLOSE` job then takes down everything beneath it - so the caller
|
|
146
|
+
sees 124 either way. See README.md's
|
|
147
|
+
"Chaining these tools together" for the full explanation.
|
|
148
|
+
|
|
67
149
|
## Elevation / desktop responsiveness
|
|
68
150
|
|
|
69
151
|
- `admin <command> [args...]` — runs elevated (as Administrator); triggers the
|
|
70
152
|
standard UAC consent prompt if the calling shell isn't already elevated, runs
|
|
71
|
-
inline with no extra prompt if it is.
|
|
153
|
+
inline with no extra prompt if it is. Blocking elevation wrapper with the same
|
|
154
|
+
wait-and-propagate-exit-code semantics as the other wrappers - it does not set
|
|
155
|
+
a priority class like `idle` does.
|
|
72
156
|
- `uiup` (no arguments) — one-shot `HIGH` priority boost for the live
|
|
73
157
|
shell/UI/audio processes (`explorer`, `dwm`, `sihost`,
|
|
74
158
|
`ShellExperienceHost`, `StartMenuExperienceHost`, `StartMenu`, `SearchApp`,
|
|
75
|
-
`audiodg`)
|
|
76
|
-
underneath
|
|
159
|
+
`audiodg`), intended to improve desktop responsiveness while heavy
|
|
160
|
+
background work runs underneath - a best-effort one-shot tweak, not a
|
|
161
|
+
guarantee (memory pressure, I/O saturation, driver/GPU stalls, or a
|
|
162
|
+
realtime workload elsewhere can still make the desktop stutter). Self-
|
|
163
|
+
elevates via UAC. Does **not** affect apps launched from
|
|
77
164
|
Explorer afterward (`HIGH` isn't inherited by default).
|
|
78
165
|
|
|
79
166
|
## Argument safety
|
|
@@ -104,6 +191,14 @@ depends on the calling shell:
|
|
|
104
191
|
| cmd.exe, or PATHEXT-based resolution (e.g. Node's `child_process` — `PATHEXT` doesn't include `.PS1` by default) | `name.bat` | corrupted before `.ps1` ever runs |
|
|
105
192
|
| POSIX shell (Git Bash only — ignores `PATHEXT`; WSL not supported) | `name` (extensionless shim) | full argument safety — `exec`s straight into `name.ps1` with MSYS argument conversion disabled, same as PowerShell |
|
|
106
193
|
|
|
194
|
+
Known limitation: the shims' `MSYS2_ARG_CONV_EXCL='*'` (what keeps their own
|
|
195
|
+
arguments intact) is inherited by the wrapped command tree, so an MSYS
|
|
196
|
+
program run *inside* the wrapped command (an inner `bash`/`sh` or a
|
|
197
|
+
`#!/bin/sh` git hook — not a native program) stops converting POSIX paths in
|
|
198
|
+
its own children's arguments (`caps 600 bash -c 'node /c/proj/run.js'` fails
|
|
199
|
+
to find the module). The shim's own arguments and native wrapped commands are
|
|
200
|
+
unaffected.
|
|
201
|
+
|
|
107
202
|
The `.bat` file corrupts any literal `%` in its arguments before the command,
|
|
108
203
|
and before `.ps1` (and its fail-closed `%` check), ever runs at all
|
|
109
204
|
(cmd.exe's own batch-parameter substitution rescanning for `%...%` patterns
|
package/bin/cap.ps1
DELETED
|
@@ -1,269 +0,0 @@
|
|
|
1
|
-
# SPDX-License-Identifier: MIT OR Apache-2.0
|
|
2
|
-
# win-nice: managed-file
|
|
3
|
-
# Deliberately no param()/[CmdletBinding()]: a declared parameter name (even
|
|
4
|
-
# without a [Parameter()] attribute) can still be ambiguously prefix-matched by
|
|
5
|
-
# flags meant for the wrapped command (e.g. "-p" matching "-Percent"). Reading
|
|
6
|
-
# everything from $args sidesteps PowerShell's parameter binder entirely.
|
|
7
|
-
if ($args.Count -lt 2) {
|
|
8
|
-
Write-Error "usage: cap <percent 1-100> <command> [args...]"
|
|
9
|
-
exit 1
|
|
10
|
-
}
|
|
11
|
-
$percentValue = 0
|
|
12
|
-
if (-not [int]::TryParse($args[0], [ref]$percentValue) -or $percentValue -lt 1 -or $percentValue -gt 100) {
|
|
13
|
-
Write-Error "usage: cap <percent 1-100> <command> [args...]"
|
|
14
|
-
exit 1
|
|
15
|
-
}
|
|
16
|
-
$Command = @($args[1..($args.Count - 1)])
|
|
17
|
-
|
|
18
|
-
# Fallback command line for when the target isn't a directly-launchable .exe (see
|
|
19
|
-
# CapLauncher.Run below) - re-parsed by cmd.exe (via "cmd.exe /c"), so quoting must
|
|
20
|
-
# neutralize its operators (&|<>^) and not just whitespace, or e.g. "A&B" gets split
|
|
21
|
-
# into two commands. NOTE: a literal "%" in an argument can still trigger cmd.exe
|
|
22
|
-
# environment-variable expansion (e.g. "%PATH%") even when quoted, and cmd.exe pairs
|
|
23
|
-
# up "%" characters across argument/quote boundaries - two unrelated arguments that
|
|
24
|
-
# each contain one "%" can corrupt each other. There is no reliable per-character
|
|
25
|
-
# escape for this at the cmd.exe /c level; it's a known, inherent limitation shared
|
|
26
|
-
# by anything that shells out through cmd.exe (Node's own child_process included).
|
|
27
|
-
# This fallback path only runs for .bat/.cmd/builtin targets - a direct .exe target
|
|
28
|
-
# never goes through cmd.exe at all, so it isn't exposed to this limitation.
|
|
29
|
-
$commandLine = ($Command | ForEach-Object {
|
|
30
|
-
$escaped = $_ -replace '"', '\"'
|
|
31
|
-
if ($escaped -eq '' -or $escaped -match '[\s"&|<>^]') { '"' + $escaped + '"' } else { $escaped }
|
|
32
|
-
}) -join ' '
|
|
33
|
-
|
|
34
|
-
$source = @"
|
|
35
|
-
using System;
|
|
36
|
-
using System.Runtime.InteropServices;
|
|
37
|
-
using System.Text;
|
|
38
|
-
|
|
39
|
-
public static class CapLauncher
|
|
40
|
-
{
|
|
41
|
-
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
|
42
|
-
struct STARTUPINFO
|
|
43
|
-
{
|
|
44
|
-
public int cb;
|
|
45
|
-
public string lpReserved;
|
|
46
|
-
public string lpDesktop;
|
|
47
|
-
public string lpTitle;
|
|
48
|
-
public int dwX;
|
|
49
|
-
public int dwY;
|
|
50
|
-
public int dwXSize;
|
|
51
|
-
public int dwYSize;
|
|
52
|
-
public int dwXCountChars;
|
|
53
|
-
public int dwYCountChars;
|
|
54
|
-
public int dwFillAttribute;
|
|
55
|
-
public int dwFlags;
|
|
56
|
-
public short wShowWindow;
|
|
57
|
-
public short cbReserved2;
|
|
58
|
-
public IntPtr lpReserved2;
|
|
59
|
-
public IntPtr hStdInput;
|
|
60
|
-
public IntPtr hStdOutput;
|
|
61
|
-
public IntPtr hStdError;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
[StructLayout(LayoutKind.Sequential)]
|
|
65
|
-
struct PROCESS_INFORMATION
|
|
66
|
-
{
|
|
67
|
-
public IntPtr hProcess;
|
|
68
|
-
public IntPtr hThread;
|
|
69
|
-
public int dwProcessId;
|
|
70
|
-
public int dwThreadId;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
[StructLayout(LayoutKind.Sequential)]
|
|
74
|
-
struct JOBOBJECT_CPU_RATE_CONTROL_INFORMATION
|
|
75
|
-
{
|
|
76
|
-
public uint ControlFlags;
|
|
77
|
-
public uint CpuRate;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
|
81
|
-
static extern bool CreateProcess(string lpApplicationName, StringBuilder lpCommandLine,
|
|
82
|
-
IntPtr lpProcessAttributes, IntPtr lpThreadAttributes, bool bInheritHandles,
|
|
83
|
-
uint dwCreationFlags, IntPtr lpEnvironment, string lpCurrentDirectory,
|
|
84
|
-
ref STARTUPINFO lpStartupInfo, out PROCESS_INFORMATION lpProcessInformation);
|
|
85
|
-
|
|
86
|
-
[DllImport("kernel32.dll", SetLastError = true)]
|
|
87
|
-
static extern IntPtr CreateJobObject(IntPtr lpJobAttributes, string lpName);
|
|
88
|
-
|
|
89
|
-
[DllImport("kernel32.dll", SetLastError = true)]
|
|
90
|
-
static extern bool SetInformationJobObject(IntPtr hJob, int JobObjectInfoClass, IntPtr lpJobObjectInfo, uint cbJobObjectInfoLength);
|
|
91
|
-
|
|
92
|
-
[DllImport("kernel32.dll", SetLastError = true)]
|
|
93
|
-
static extern bool AssignProcessToJobObject(IntPtr hJob, IntPtr hProcess);
|
|
94
|
-
|
|
95
|
-
[DllImport("kernel32.dll", SetLastError = true)]
|
|
96
|
-
static extern uint ResumeThread(IntPtr hThread);
|
|
97
|
-
|
|
98
|
-
[DllImport("kernel32.dll", SetLastError = true)]
|
|
99
|
-
static extern uint WaitForSingleObject(IntPtr hHandle, uint dwMilliseconds);
|
|
100
|
-
|
|
101
|
-
[DllImport("kernel32.dll", SetLastError = true)]
|
|
102
|
-
static extern bool GetExitCodeProcess(IntPtr hProcess, out uint lpExitCode);
|
|
103
|
-
|
|
104
|
-
[DllImport("kernel32.dll", SetLastError = true)]
|
|
105
|
-
static extern bool TerminateProcess(IntPtr hProcess, uint uExitCode);
|
|
106
|
-
|
|
107
|
-
[DllImport("kernel32.dll")]
|
|
108
|
-
static extern bool CloseHandle(IntPtr hObject);
|
|
109
|
-
|
|
110
|
-
const uint CREATE_SUSPENDED = 0x00000004;
|
|
111
|
-
const int JobObjectCpuRateControlInformation = 15;
|
|
112
|
-
const uint JOB_OBJECT_CPU_RATE_CONTROL_ENABLE = 0x1;
|
|
113
|
-
const uint JOB_OBJECT_CPU_RATE_CONTROL_HARD_CAP = 0x4;
|
|
114
|
-
|
|
115
|
-
// Standard MSVCRT/CommandLineToArgvW quoting: safe for a directly-launched .exe's
|
|
116
|
-
// own argv parsing. No cmd.exe involved on this path, so none of its operator or
|
|
117
|
-
// "%" expansion semantics apply - this is the safe path, used whenever possible.
|
|
118
|
-
static string ArgvQuote(string arg)
|
|
119
|
-
{
|
|
120
|
-
if (arg.Length > 0 && arg.IndexOfAny(new char[] { ' ', '\t', '\n', '\v', '"' }) < 0)
|
|
121
|
-
return arg;
|
|
122
|
-
|
|
123
|
-
var result = new StringBuilder();
|
|
124
|
-
result.Append('"');
|
|
125
|
-
int backslashes = 0;
|
|
126
|
-
foreach (char c in arg)
|
|
127
|
-
{
|
|
128
|
-
if (c == '\\')
|
|
129
|
-
{
|
|
130
|
-
backslashes++;
|
|
131
|
-
}
|
|
132
|
-
else if (c == '"')
|
|
133
|
-
{
|
|
134
|
-
result.Append('\\', backslashes * 2 + 1);
|
|
135
|
-
result.Append('"');
|
|
136
|
-
backslashes = 0;
|
|
137
|
-
}
|
|
138
|
-
else
|
|
139
|
-
{
|
|
140
|
-
if (backslashes > 0) { result.Append('\\', backslashes); backslashes = 0; }
|
|
141
|
-
result.Append(c);
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
if (backslashes > 0) result.Append('\\', backslashes * 2);
|
|
145
|
-
result.Append('"');
|
|
146
|
-
return result.ToString();
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
static string BuildArgvCommandLine(string[] argv)
|
|
150
|
-
{
|
|
151
|
-
var parts = new string[argv.Length];
|
|
152
|
-
for (int i = 0; i < argv.Length; i++) parts[i] = ArgvQuote(argv[i]);
|
|
153
|
-
return string.Join(" ", parts);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
public static int Run(int percent, string[] argv, string cmdExeCommandLine)
|
|
157
|
-
{
|
|
158
|
-
IntPtr hJob = CreateJobObject(IntPtr.Zero, null);
|
|
159
|
-
if (hJob == IntPtr.Zero)
|
|
160
|
-
throw new InvalidOperationException("CreateJobObject failed: " + Marshal.GetLastWin32Error());
|
|
161
|
-
|
|
162
|
-
var cpuInfo = new JOBOBJECT_CPU_RATE_CONTROL_INFORMATION
|
|
163
|
-
{
|
|
164
|
-
ControlFlags = JOB_OBJECT_CPU_RATE_CONTROL_ENABLE | JOB_OBJECT_CPU_RATE_CONTROL_HARD_CAP,
|
|
165
|
-
CpuRate = (uint)(percent * 100)
|
|
166
|
-
};
|
|
167
|
-
int size = Marshal.SizeOf(cpuInfo);
|
|
168
|
-
IntPtr ptr = Marshal.AllocHGlobal(size);
|
|
169
|
-
Marshal.StructureToPtr(cpuInfo, ptr, false);
|
|
170
|
-
bool ok = SetInformationJobObject(hJob, JobObjectCpuRateControlInformation, ptr, (uint)size);
|
|
171
|
-
Marshal.FreeHGlobal(ptr);
|
|
172
|
-
if (!ok)
|
|
173
|
-
{
|
|
174
|
-
CloseHandle(hJob);
|
|
175
|
-
throw new InvalidOperationException("SetInformationJobObject failed: " + Marshal.GetLastWin32Error());
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
var si = new STARTUPINFO();
|
|
179
|
-
si.cb = Marshal.SizeOf(si);
|
|
180
|
-
PROCESS_INFORMATION pi = new PROCESS_INFORMATION();
|
|
181
|
-
|
|
182
|
-
// Try launching the target directly first (no shell at all) - unless it's a
|
|
183
|
-
// .bat/.cmd file. CreateProcess has an undocumented-but-real fallback of its
|
|
184
|
-
// own for those: instead of failing, it silently re-invokes them through
|
|
185
|
-
// cmd.exe using OUR unescaped argv text (ArgvQuote only protects CRT argv
|
|
186
|
-
// parsing, not cmd.exe's operators), reopening the exact "A&B" splits this
|
|
187
|
-
// whole file exists to prevent. A bare name with no extension is safe either
|
|
188
|
-
// way: CreateProcess only ever auto-appends ".exe" to it, never ".bat/.cmd",
|
|
189
|
-
// so it fails cleanly (ERROR_FILE_NOT_FOUND) when only a same-named .bat/.cmd
|
|
190
|
-
// exists, and falls through to the escaped path below.
|
|
191
|
-
bool isBatOrCmd = argv.Length > 0 && (
|
|
192
|
-
argv[0].EndsWith(".bat", StringComparison.OrdinalIgnoreCase) ||
|
|
193
|
-
argv[0].EndsWith(".cmd", StringComparison.OrdinalIgnoreCase));
|
|
194
|
-
|
|
195
|
-
bool created = false;
|
|
196
|
-
if (!isBatOrCmd)
|
|
197
|
-
{
|
|
198
|
-
var directCommandLine = new StringBuilder(BuildArgvCommandLine(argv));
|
|
199
|
-
created = CreateProcess(null, directCommandLine, IntPtr.Zero, IntPtr.Zero, true,
|
|
200
|
-
CREATE_SUSPENDED, IntPtr.Zero, null, ref si, out pi);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
if (!created)
|
|
204
|
-
{
|
|
205
|
-
// Falling back to cmd.exe /c: a literal "%" in any argument could now
|
|
206
|
-
// trigger environment-variable expansion (cmd.exe pairs up "%" characters
|
|
207
|
-
// across the whole command line, even across separate arguments) and
|
|
208
|
-
// change what actually runs. Fail loudly here instead of silently risking
|
|
209
|
-
// that - there's no reliable per-character escape for "%" at this level.
|
|
210
|
-
foreach (var a in argv)
|
|
211
|
-
{
|
|
212
|
-
if (a.IndexOf('%') >= 0)
|
|
213
|
-
throw new InvalidOperationException(
|
|
214
|
-
"Refusing to run: argument contains '%' and the target needs the cmd.exe " +
|
|
215
|
-
"fallback (not a directly-launchable .exe), where '%' can trigger unintended " +
|
|
216
|
-
"environment-variable expansion. See README's Argument handling section.");
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
string cmdExe = Environment.SystemDirectory + "\\cmd.exe";
|
|
220
|
-
// /d: skip HKCU AutoRun (user-writable registry key). /v:off: disable delayed
|
|
221
|
-
// expansion so "!var!" in an argument can't be expanded. /s plus the extra outer
|
|
222
|
-
// quote pair: cmd's /S rule strips exactly that outer pair and leaves the rest of
|
|
223
|
-
// the string untouched - without /S, cmd strips the first and last quote of the
|
|
224
|
-
// whole line instead, which breaks quoting whenever the target path itself needs
|
|
225
|
-
// quotes AND another argument is also quoted.
|
|
226
|
-
var shellCommandLine = new StringBuilder("\"" + cmdExe + "\" /d /v:off /s /c \"" + cmdExeCommandLine + "\"");
|
|
227
|
-
created = CreateProcess(null, shellCommandLine, IntPtr.Zero, IntPtr.Zero, true,
|
|
228
|
-
CREATE_SUSPENDED, IntPtr.Zero, null, ref si, out pi);
|
|
229
|
-
if (!created)
|
|
230
|
-
{
|
|
231
|
-
CloseHandle(hJob);
|
|
232
|
-
throw new InvalidOperationException("CreateProcess failed: " + Marshal.GetLastWin32Error());
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
if (!AssignProcessToJobObject(hJob, pi.hProcess))
|
|
237
|
-
{
|
|
238
|
-
// Can't guarantee the cap - kill instead of letting it run uncapped and orphaned.
|
|
239
|
-
int err = Marshal.GetLastWin32Error();
|
|
240
|
-
TerminateProcess(pi.hProcess, 1);
|
|
241
|
-
CloseHandle(pi.hThread);
|
|
242
|
-
CloseHandle(pi.hProcess);
|
|
243
|
-
CloseHandle(hJob);
|
|
244
|
-
throw new InvalidOperationException("AssignProcessToJobObject failed: " + err);
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
ResumeThread(pi.hThread);
|
|
248
|
-
WaitForSingleObject(pi.hProcess, 0xFFFFFFFF);
|
|
249
|
-
|
|
250
|
-
uint exitCode;
|
|
251
|
-
GetExitCodeProcess(pi.hProcess, out exitCode);
|
|
252
|
-
|
|
253
|
-
CloseHandle(pi.hThread);
|
|
254
|
-
CloseHandle(pi.hProcess);
|
|
255
|
-
CloseHandle(hJob);
|
|
256
|
-
|
|
257
|
-
return (int)exitCode;
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
"@
|
|
261
|
-
|
|
262
|
-
Add-Type -TypeDefinition $source -Language CSharp
|
|
263
|
-
|
|
264
|
-
try {
|
|
265
|
-
exit ([CapLauncher]::Run($percentValue, [string[]]$Command, $commandLine))
|
|
266
|
-
} catch {
|
|
267
|
-
Write-Error $_.Exception.InnerException.Message
|
|
268
|
-
exit 1
|
|
269
|
-
}
|
package/bin/pint.bat
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
@echo off
|
|
2
|
-
:: SPDX-License-Identifier: MIT OR Apache-2.0
|
|
3
|
-
:: win-nice: managed-file
|
|
4
|
-
:: A literal "%" in any argument gets corrupted here - see cap.bat for why (a
|
|
5
|
-
:: cmd.exe batch-parameter quirk, not fixable from inside a .bat). Every other
|
|
6
|
-
:: cmd.exe metacharacter (&|<>^) survives this hop untouched. Invoking "pint"
|
|
7
|
-
:: bare from an actual PowerShell session skips this file (pint.ps1 preferred).
|
|
8
|
-
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0pint.ps1" %*
|