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/bin/caps.ps1
ADDED
|
@@ -0,0 +1,589 @@
|
|
|
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. "-s" matching "-Size"). Reading
|
|
6
|
+
# everything from $args sidesteps PowerShell's parameter binder entirely.
|
|
7
|
+
#
|
|
8
|
+
# Why caps exists: capc/capt/capm deliberately impose no timeout - a quota tool
|
|
9
|
+
# shouldn't unilaterally decide a legitimate long build is stuck (same precedent
|
|
10
|
+
# as nice/cpulimit, which bound resource use but never wall-clock time). caps is
|
|
11
|
+
# the complement: the caller has already decided "kill this after N seconds no
|
|
12
|
+
# matter what", and the Job Object (not process walking) is what makes "and
|
|
13
|
+
# nothing survives it" true for the whole spawned tree.
|
|
14
|
+
$usage = "usage: caps <seconds> <command> [args...] (seconds: positive whole or " +
|
|
15
|
+
"decimal number, e.g. 2 or 2.5 - converted to whole milliseconds; minimum " +
|
|
16
|
+
"1 ms, maximum 4294967294 ms (~49.7 days) - a deliberate ceiling, " +
|
|
17
|
+
"not an API limit)"
|
|
18
|
+
|
|
19
|
+
if ($args.Count -lt 2) {
|
|
20
|
+
Write-Error $usage
|
|
21
|
+
exit 1
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
$secondsArg = $args[0]
|
|
25
|
+
if ($secondsArg -notmatch '^(?<num>\d+(\.\d+)?)$') {
|
|
26
|
+
Write-Error $usage
|
|
27
|
+
exit 1
|
|
28
|
+
}
|
|
29
|
+
# TryParse, not a raw [double] cast: an arbitrarily long digit string (the
|
|
30
|
+
# regex above has no length limit) overflows a plain [double] cast with a
|
|
31
|
+
# raw, unhandled PowerShell conversion error (path/line number and all) -
|
|
32
|
+
# TryParse fails cleanly instead, so every invalid <seconds> hits the same
|
|
33
|
+
# single usage message regardless of why it's invalid. (Same reason capm.ps1
|
|
34
|
+
# documents for its own <size>.)
|
|
35
|
+
$secondsNum = 0.0
|
|
36
|
+
$numOk = [double]::TryParse($Matches['num'], [System.Globalization.NumberStyles]::Float,
|
|
37
|
+
[System.Globalization.CultureInfo]::InvariantCulture, [ref]$secondsNum)
|
|
38
|
+
if (-not $numOk -or [double]::IsNaN($secondsNum) -or [double]::IsInfinity($secondsNum) -or $secondsNum -le 0) {
|
|
39
|
+
Write-Error "caps: <seconds> is out of range. $usage"
|
|
40
|
+
exit 1
|
|
41
|
+
}
|
|
42
|
+
# The upper bound is a deliberate usage ceiling, not an API limit: the deadline
|
|
43
|
+
# is an absolute 64-bit FILETIME armed via SetWaitableTimer, and the wait below
|
|
44
|
+
# passes dwMilliseconds = INFINITE unconditionally, so no uint32 boundary
|
|
45
|
+
# applies to it. ~49.7 days comfortably covers any real use; anything larger is
|
|
46
|
+
# rejected as the usage error it is instead, never silently truncated. Floor to
|
|
47
|
+
# whole milliseconds so a sub-millisecond value can neither round up nor
|
|
48
|
+
# truncate to a meaningless 0 unnoticed.
|
|
49
|
+
$timeoutMsDouble = $secondsNum * 1000.0
|
|
50
|
+
if ($timeoutMsDouble -lt 1 -or $timeoutMsDouble -gt 4294967294) {
|
|
51
|
+
Write-Error "caps: <seconds> is out of range. $usage"
|
|
52
|
+
exit 1
|
|
53
|
+
}
|
|
54
|
+
$timeoutMs = [uint32][math]::Floor($timeoutMsDouble)
|
|
55
|
+
$Command = @($args[1..($args.Count - 1)])
|
|
56
|
+
|
|
57
|
+
# Fallback command line for when the target isn't a directly-launchable .exe (see
|
|
58
|
+
# CapsLauncher.Run below) - re-parsed by cmd.exe (via "cmd.exe /c"), so quoting must
|
|
59
|
+
# neutralize its operators (&|<>^) and not just whitespace - see capc.ps1 for the
|
|
60
|
+
# same logic and its documented "%" limitation.
|
|
61
|
+
$commandLine = ($Command | ForEach-Object {
|
|
62
|
+
$escaped = $_ -replace '"', '\"'
|
|
63
|
+
if ($escaped -eq '' -or $escaped -match '[\s"&|<>^]') { '"' + $escaped + '"' } else { $escaped }
|
|
64
|
+
}) -join ' '
|
|
65
|
+
|
|
66
|
+
$source = @"
|
|
67
|
+
using System;
|
|
68
|
+
using System.Runtime.InteropServices;
|
|
69
|
+
using System.Text;
|
|
70
|
+
|
|
71
|
+
public static class CapsLauncher
|
|
72
|
+
{
|
|
73
|
+
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
|
74
|
+
struct STARTUPINFO
|
|
75
|
+
{
|
|
76
|
+
public int cb;
|
|
77
|
+
public string lpReserved;
|
|
78
|
+
public string lpDesktop;
|
|
79
|
+
public string lpTitle;
|
|
80
|
+
public int dwX;
|
|
81
|
+
public int dwY;
|
|
82
|
+
public int dwXSize;
|
|
83
|
+
public int dwYSize;
|
|
84
|
+
public int dwXCountChars;
|
|
85
|
+
public int dwYCountChars;
|
|
86
|
+
public int dwFillAttribute;
|
|
87
|
+
public int dwFlags;
|
|
88
|
+
public short wShowWindow;
|
|
89
|
+
public short cbReserved2;
|
|
90
|
+
public IntPtr lpReserved2;
|
|
91
|
+
public IntPtr hStdInput;
|
|
92
|
+
public IntPtr hStdOutput;
|
|
93
|
+
public IntPtr hStdError;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
97
|
+
struct PROCESS_INFORMATION
|
|
98
|
+
{
|
|
99
|
+
public IntPtr hProcess;
|
|
100
|
+
public IntPtr hThread;
|
|
101
|
+
public int dwProcessId;
|
|
102
|
+
public int dwThreadId;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
106
|
+
struct JOBOBJECT_BASIC_LIMIT_INFORMATION
|
|
107
|
+
{
|
|
108
|
+
public long PerProcessUserTimeLimit;
|
|
109
|
+
public long PerJobUserTimeLimit;
|
|
110
|
+
public uint LimitFlags;
|
|
111
|
+
public UIntPtr MinimumWorkingSetSize;
|
|
112
|
+
public UIntPtr MaximumWorkingSetSize;
|
|
113
|
+
public uint ActiveProcessLimit;
|
|
114
|
+
public UIntPtr Affinity;
|
|
115
|
+
public uint PriorityClass;
|
|
116
|
+
public uint SchedulingClass;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
120
|
+
struct IO_COUNTERS
|
|
121
|
+
{
|
|
122
|
+
public ulong ReadOperationCount;
|
|
123
|
+
public ulong WriteOperationCount;
|
|
124
|
+
public ulong OtherOperationCount;
|
|
125
|
+
public ulong ReadTransferCount;
|
|
126
|
+
public ulong WriteTransferCount;
|
|
127
|
+
public ulong OtherTransferCount;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
131
|
+
struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION
|
|
132
|
+
{
|
|
133
|
+
public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation;
|
|
134
|
+
public IO_COUNTERS IoInfo;
|
|
135
|
+
public UIntPtr ProcessMemoryLimit;
|
|
136
|
+
public UIntPtr JobMemoryLimit;
|
|
137
|
+
public UIntPtr PeakProcessMemoryUsed;
|
|
138
|
+
public UIntPtr PeakJobMemoryUsed;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
|
142
|
+
static extern bool CreateProcess(string lpApplicationName, StringBuilder lpCommandLine,
|
|
143
|
+
IntPtr lpProcessAttributes, IntPtr lpThreadAttributes, bool bInheritHandles,
|
|
144
|
+
uint dwCreationFlags, IntPtr lpEnvironment, string lpCurrentDirectory,
|
|
145
|
+
ref STARTUPINFO lpStartupInfo, out PROCESS_INFORMATION lpProcessInformation);
|
|
146
|
+
|
|
147
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
148
|
+
static extern IntPtr CreateJobObject(IntPtr lpJobAttributes, string lpName);
|
|
149
|
+
|
|
150
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
151
|
+
static extern bool SetInformationJobObject(IntPtr hJob, int JobObjectInfoClass, IntPtr lpJobObjectInfo, uint cbJobObjectInfoLength);
|
|
152
|
+
|
|
153
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
154
|
+
static extern bool AssignProcessToJobObject(IntPtr hJob, IntPtr hProcess);
|
|
155
|
+
|
|
156
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
157
|
+
static extern uint ResumeThread(IntPtr hThread);
|
|
158
|
+
|
|
159
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
160
|
+
static extern IntPtr CreateWaitableTimer(IntPtr lpTimerAttributes, bool bManualReset, string lpTimerName);
|
|
161
|
+
|
|
162
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
163
|
+
static extern bool SetWaitableTimer(IntPtr hTimer, ref long pDueTime, int lPeriod,
|
|
164
|
+
IntPtr pfnCompletionRoutine, IntPtr lpArgToCompletionRoutine, bool fResume);
|
|
165
|
+
|
|
166
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
167
|
+
static extern uint WaitForMultipleObjects(uint nCount, IntPtr[] lpHandles, bool bWaitAll, uint dwMilliseconds);
|
|
168
|
+
|
|
169
|
+
// Authoritative exit-timestamp source for the tie-break in Run(): when
|
|
170
|
+
// both wait handles are already signaled, only the kernel's own record of
|
|
171
|
+
// WHEN the process exited can say whether that happened before the
|
|
172
|
+
// deadline. FILETIME fields combine into one 64-bit UTC FILETIME value
|
|
173
|
+
// (high dword first), the same representation and epoch as the timer's
|
|
174
|
+
// absolute due time.
|
|
175
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
176
|
+
struct FILETIME
|
|
177
|
+
{
|
|
178
|
+
public uint dwLowDateTime;
|
|
179
|
+
public uint dwHighDateTime;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
183
|
+
static extern bool GetProcessTimes(IntPtr hProcess, out FILETIME lpCreationTime,
|
|
184
|
+
out FILETIME lpExitTime, out FILETIME lpKernelTime, out FILETIME lpUserTime);
|
|
185
|
+
|
|
186
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
187
|
+
static extern bool GetExitCodeProcess(IntPtr hProcess, out uint lpExitCode);
|
|
188
|
+
|
|
189
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
190
|
+
static extern bool TerminateProcess(IntPtr hProcess, uint uExitCode);
|
|
191
|
+
|
|
192
|
+
// The timeout path's whole-tree kill: TerminateJobObject terminates every
|
|
193
|
+
// process still assigned to the job in one atomic kernel call - the direct
|
|
194
|
+
// child and everything it spawned, no process-tree walking, no window
|
|
195
|
+
// where a descendant outlives the child. (TerminateProcess above stays for
|
|
196
|
+
// the failure paths, where the best-effort kill can only ever target the
|
|
197
|
+
// one process handle in hand.)
|
|
198
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
199
|
+
static extern bool TerminateJobObject(IntPtr hJob, uint uExitCode);
|
|
200
|
+
|
|
201
|
+
[DllImport("kernel32.dll")]
|
|
202
|
+
static extern bool CloseHandle(IntPtr hObject);
|
|
203
|
+
|
|
204
|
+
const uint CREATE_SUSPENDED = 0x00000004;
|
|
205
|
+
const int JobObjectExtendedLimitInformation = 9;
|
|
206
|
+
const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000;
|
|
207
|
+
|
|
208
|
+
// Standard MSVCRT/CommandLineToArgvW quoting: safe for a directly-launched .exe's
|
|
209
|
+
// own argv parsing. No cmd.exe involved on this path, so none of its operator or
|
|
210
|
+
// "%" expansion semantics apply - this is the safe path, used whenever possible.
|
|
211
|
+
static string ArgvQuote(string arg)
|
|
212
|
+
{
|
|
213
|
+
if (arg.Length > 0 && arg.IndexOfAny(new char[] { ' ', '\t', '\n', '\v', '"' }) < 0)
|
|
214
|
+
return arg;
|
|
215
|
+
|
|
216
|
+
var result = new StringBuilder();
|
|
217
|
+
result.Append('"');
|
|
218
|
+
int backslashes = 0;
|
|
219
|
+
foreach (char c in arg)
|
|
220
|
+
{
|
|
221
|
+
if (c == '\\')
|
|
222
|
+
{
|
|
223
|
+
backslashes++;
|
|
224
|
+
}
|
|
225
|
+
else if (c == '"')
|
|
226
|
+
{
|
|
227
|
+
result.Append('\\', backslashes * 2 + 1);
|
|
228
|
+
result.Append('"');
|
|
229
|
+
backslashes = 0;
|
|
230
|
+
}
|
|
231
|
+
else
|
|
232
|
+
{
|
|
233
|
+
if (backslashes > 0) { result.Append('\\', backslashes); backslashes = 0; }
|
|
234
|
+
result.Append(c);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (backslashes > 0) result.Append('\\', backslashes * 2);
|
|
238
|
+
result.Append('"');
|
|
239
|
+
return result.ToString();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
static string BuildArgvCommandLine(string[] argv)
|
|
243
|
+
{
|
|
244
|
+
var parts = new string[argv.Length];
|
|
245
|
+
for (int i = 0; i < argv.Length; i++) parts[i] = ArgvQuote(argv[i]);
|
|
246
|
+
return string.Join(" ", parts);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
public static int Run(uint timeoutMs, string[] argv, string cmdExeCommandLine)
|
|
250
|
+
{
|
|
251
|
+
IntPtr hJob = CreateJobObject(IntPtr.Zero, null);
|
|
252
|
+
if (hJob == IntPtr.Zero)
|
|
253
|
+
throw new InvalidOperationException("CreateJobObject failed: " + Marshal.GetLastWin32Error());
|
|
254
|
+
|
|
255
|
+
// Single owner for every handle this method acquires. The finally below closes
|
|
256
|
+
// hThread/hProcess/hJob/hTimer - in that order - on EVERY way out: normal
|
|
257
|
+
// return, any of the InvalidOperationExceptions thrown here, and an
|
|
258
|
+
// unexpected managed exception (allocation/marshalling failure) between
|
|
259
|
+
// acquisition and use. hProcess/hThread stay IntPtr.Zero until CreateProcess
|
|
260
|
+
// has actually succeeded, hTimer until the deadline timer is created just
|
|
261
|
+
// before the wait, so each handle is closed exactly once and only if it
|
|
262
|
+
// was really acquired.
|
|
263
|
+
IntPtr hProcess = IntPtr.Zero;
|
|
264
|
+
IntPtr hThread = IntPtr.Zero;
|
|
265
|
+
IntPtr hTimer = IntPtr.Zero;
|
|
266
|
+
try
|
|
267
|
+
{
|
|
268
|
+
var extInfo = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION
|
|
269
|
+
{
|
|
270
|
+
BasicLimitInformation = new JOBOBJECT_BASIC_LIMIT_INFORMATION
|
|
271
|
+
{
|
|
272
|
+
// KILL_ON_JOB_CLOSE: the cleanup in the finally below only runs
|
|
273
|
+
// if this launcher process survives to execute it. Killed from
|
|
274
|
+
// outside (taskkill without /T, a crash), nothing in-process
|
|
275
|
+
// ever runs - without this flag the last job handle dying with
|
|
276
|
+
// the process would leave every process still assigned to the
|
|
277
|
+
// job running on, untracked and unmanaged. With it, Windows
|
|
278
|
+
// itself terminates the whole job at that moment.
|
|
279
|
+
//
|
|
280
|
+
// A backstop only, never a normal exit mechanism: the last
|
|
281
|
+
// handle closing terminates every process still assigned to
|
|
282
|
+
// the job FOR ANY reason, including this wrapper's own orderly
|
|
283
|
+
// close in the finally - and the bounded wait below only waits
|
|
284
|
+
// on the directly wrapped root process, so a daemon it spawned
|
|
285
|
+
// and left running can still be in the job then. Killing a
|
|
286
|
+
// daemon on a SUCCESSFUL exit would break the documented
|
|
287
|
+
// daemon-survival contract (README: a limit sticks to any
|
|
288
|
+
// daemon the wrapped command leaves running, for that daemon's
|
|
289
|
+
// whole lifetime), so the success path clears this flag before
|
|
290
|
+
// returning; the timeout path keeps it (it kills the job
|
|
291
|
+
// itself). See capc.ps1 for the full write-up.
|
|
292
|
+
// caps sets no other limit flag - the job exists purely so the
|
|
293
|
+
// timeout kill (and that close-of-business kill) covers the
|
|
294
|
+
// whole process tree, not to impose any resource ceiling.
|
|
295
|
+
LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
int size = Marshal.SizeOf(extInfo);
|
|
299
|
+
IntPtr ptr = Marshal.AllocHGlobal(size);
|
|
300
|
+
bool ok;
|
|
301
|
+
try
|
|
302
|
+
{
|
|
303
|
+
Marshal.StructureToPtr(extInfo, ptr, false);
|
|
304
|
+
ok = SetInformationJobObject(hJob, JobObjectExtendedLimitInformation, ptr, (uint)size);
|
|
305
|
+
}
|
|
306
|
+
finally
|
|
307
|
+
{
|
|
308
|
+
Marshal.FreeHGlobal(ptr);
|
|
309
|
+
}
|
|
310
|
+
if (!ok)
|
|
311
|
+
throw new InvalidOperationException("SetInformationJobObject failed: " + Marshal.GetLastWin32Error());
|
|
312
|
+
|
|
313
|
+
var si = new STARTUPINFO();
|
|
314
|
+
si.cb = Marshal.SizeOf(si);
|
|
315
|
+
PROCESS_INFORMATION pi = new PROCESS_INFORMATION();
|
|
316
|
+
|
|
317
|
+
// See capc.ps1 for why .bat/.cmd targets skip the direct attempt entirely:
|
|
318
|
+
// CreateProcess silently re-invokes them through cmd.exe on its own, using
|
|
319
|
+
// unescaped text, instead of failing the way a genuinely missing exe would.
|
|
320
|
+
bool isBatOrCmd = argv.Length > 0 && (
|
|
321
|
+
argv[0].EndsWith(".bat", StringComparison.OrdinalIgnoreCase) ||
|
|
322
|
+
argv[0].EndsWith(".cmd", StringComparison.OrdinalIgnoreCase));
|
|
323
|
+
|
|
324
|
+
bool created = false;
|
|
325
|
+
if (!isBatOrCmd)
|
|
326
|
+
{
|
|
327
|
+
var directCommandLine = new StringBuilder(BuildArgvCommandLine(argv));
|
|
328
|
+
created = CreateProcess(null, directCommandLine, IntPtr.Zero, IntPtr.Zero, true,
|
|
329
|
+
CREATE_SUSPENDED, IntPtr.Zero, null, ref si, out pi);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (!created)
|
|
333
|
+
{
|
|
334
|
+
// Falling back to cmd.exe /c: a literal "%" in any argument could now
|
|
335
|
+
// trigger environment-variable expansion (cmd.exe pairs up "%" characters
|
|
336
|
+
// across the whole command line, even across separate arguments) and
|
|
337
|
+
// change what actually runs. Fail loudly here instead of silently risking
|
|
338
|
+
// that - there's no reliable per-character escape for "%" at this level.
|
|
339
|
+
foreach (var a in argv)
|
|
340
|
+
{
|
|
341
|
+
if (a.IndexOf('%') >= 0)
|
|
342
|
+
throw new InvalidOperationException(
|
|
343
|
+
"Refusing to run: argument contains '%' and the target needs the cmd.exe " +
|
|
344
|
+
"fallback (not a directly-launchable .exe), where '%' can trigger unintended " +
|
|
345
|
+
"environment-variable expansion. See README's Argument handling section.");
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
string cmdExe = Environment.SystemDirectory + "\\cmd.exe";
|
|
349
|
+
// /d: skip HKCU AutoRun (user-writable registry key). /v:off: disable delayed
|
|
350
|
+
// expansion so "!var!" in an argument can't be expanded. /s plus the extra outer
|
|
351
|
+
// quote pair: cmd's /S rule strips exactly that outer pair and leaves the rest of
|
|
352
|
+
// the string untouched - without /S, cmd strips the first and last quote of the
|
|
353
|
+
// whole line instead, which breaks quoting whenever the target path itself needs
|
|
354
|
+
// quotes AND another argument is also quoted.
|
|
355
|
+
var shellCommandLine = new StringBuilder("\"" + cmdExe + "\" /d /v:off /s /c \"" + cmdExeCommandLine + "\"");
|
|
356
|
+
created = CreateProcess(null, shellCommandLine, IntPtr.Zero, IntPtr.Zero, true,
|
|
357
|
+
CREATE_SUSPENDED, IntPtr.Zero, null, ref si, out pi);
|
|
358
|
+
if (!created)
|
|
359
|
+
throw new InvalidOperationException("CreateProcess failed: " + Marshal.GetLastWin32Error());
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Ownership of the child's handles transfers here, once CreateProcess has
|
|
363
|
+
// actually succeeded - from this point the finally below is what closes them.
|
|
364
|
+
hProcess = pi.hProcess;
|
|
365
|
+
hThread = pi.hThread;
|
|
366
|
+
|
|
367
|
+
if (!AssignProcessToJobObject(hJob, hProcess))
|
|
368
|
+
{
|
|
369
|
+
// Can't guarantee the cap - kill instead of letting it run uncapped and orphaned.
|
|
370
|
+
int err = Marshal.GetLastWin32Error();
|
|
371
|
+
string message = "AssignProcessToJobObject failed: " + err;
|
|
372
|
+
// Report if the best-effort kill itself also failed.
|
|
373
|
+
if (!TerminateProcess(hProcess, 1))
|
|
374
|
+
message += "; TerminateProcess also failed: " + Marshal.GetLastWin32Error();
|
|
375
|
+
throw new InvalidOperationException(message);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (ResumeThread(hThread) == 0xFFFFFFFF)
|
|
379
|
+
{
|
|
380
|
+
// Still suspended - an unbounded wait below would hang forever. Kill
|
|
381
|
+
// it instead of leaving an orphaned, permanently-suspended process.
|
|
382
|
+
int resumeErr = Marshal.GetLastWin32Error();
|
|
383
|
+
string message = "ResumeThread failed: " + resumeErr;
|
|
384
|
+
// Report if the best-effort kill itself also failed.
|
|
385
|
+
if (!TerminateProcess(hProcess, 1))
|
|
386
|
+
message += "; TerminateProcess also failed: " + Marshal.GetLastWin32Error();
|
|
387
|
+
throw new InvalidOperationException(message);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// caps' one behavioral difference from every other launcher in this
|
|
391
|
+
// repo: the wait is bounded. The PowerShell side validated the
|
|
392
|
+
// deadline into [1, 0xFFFFFFFE] ms - a deliberate usage ceiling -
|
|
393
|
+
// before the cast; the INFINITE below is passed unconditionally
|
|
394
|
+
// (the timer's absolute due time is what actually bounds the wait).
|
|
395
|
+
//
|
|
396
|
+
// The deadline is computed once as an ABSOLUTE UTC timestamp, armed
|
|
397
|
+
// into a one-shot waitable timer, and waited on TOGETHER with the
|
|
398
|
+
// process handle - it is never any kind of relative wait, not even a
|
|
399
|
+
// sliced one. WaitForSingleObject's relative dwMilliseconds does not
|
|
400
|
+
// count time spent in low-power sleep/suspend on Windows 8+, and the
|
|
401
|
+
// problem is not just ONE long wait: a relative wait ALREADY IN
|
|
402
|
+
// PROGRESS when the machine suspends keeps running down its
|
|
403
|
+
// pre-sleep remainder after the wake, however far past the deadline
|
|
404
|
+
// the clock now is - so the previous poll-slice design could still
|
|
405
|
+
// overshoot the deadline by up to a slice and even accept a
|
|
406
|
+
// post-deadline exit as on-time success. SetWaitableTimer's
|
|
407
|
+
// ABSOLUTE due time has the opposite, documented property: a timer
|
|
408
|
+
// whose due time has already passed comes up already-signaled,
|
|
409
|
+
// whenever the system next looks at it - signaled-ness is a property
|
|
410
|
+
// of the absolute clock, not of an in-progress wait. If the machine
|
|
411
|
+
// sleeps past the deadline, the timer is therefore signaled the
|
|
412
|
+
// moment anything waits on it after the wake: the deadline cannot be
|
|
413
|
+
// postponed by a leftover wait remainder, and a child exiting after
|
|
414
|
+
// the deadline can never be mistaken for an on-time success.
|
|
415
|
+
// https://learn.microsoft.com/en-us/windows/win32/api/synchapi/nf-synchapi-setwaitabletimer
|
|
416
|
+
DateTime deadlineUtc = DateTime.UtcNow.AddMilliseconds(timeoutMs);
|
|
417
|
+
|
|
418
|
+
// Manual reset, not auto-reset: once due the timer STAYS signaled,
|
|
419
|
+
// so "deadline passed" is a stable state rather than a consumable
|
|
420
|
+
// event - the process-finished vs deadline-hit outcome can't be
|
|
421
|
+
// lost to a race against signal consumption. Anonymous (null name),
|
|
422
|
+
// exactly like the job object above. On failure here the child is
|
|
423
|
+
// already running; the throw falls through to the finally, whose
|
|
424
|
+
// hJob close still carries KILL_ON_JOB_CLOSE (the release below
|
|
425
|
+
// only happens on the success path), so the whole tree is
|
|
426
|
+
// terminated by the same backstop as a non-cooperative wrapper
|
|
427
|
+
// death - nothing is left running unmanaged.
|
|
428
|
+
hTimer = CreateWaitableTimer(IntPtr.Zero, true, null);
|
|
429
|
+
if (hTimer == IntPtr.Zero)
|
|
430
|
+
throw new InvalidOperationException("CreateWaitableTimer failed: " + Marshal.GetLastWin32Error());
|
|
431
|
+
|
|
432
|
+
// Positive due time = ABSOLUTE FILETIME (100ns units since
|
|
433
|
+
// 1601-01-01 UTC), which ToFileTimeUtc() produces directly from the
|
|
434
|
+
// deadline above. lPeriod 0 = one-shot. No completion routine: APC
|
|
435
|
+
// delivery would require an alertable wait, which this never is.
|
|
436
|
+
// fResume = false, deliberately: caps must never wake a sleeping
|
|
437
|
+
// machine just to enforce a timeout - that would be a surprising
|
|
438
|
+
// and hostile side effect for a tool whose whole point is to
|
|
439
|
+
// coexist politely with the user's machine. If the machine is
|
|
440
|
+
// asleep at the due time, the timer simply comes up
|
|
441
|
+
// already-signaled on wake (the whole point of the absolute due
|
|
442
|
+
// time) and the kill happens then.
|
|
443
|
+
long timerDueTime = deadlineUtc.ToFileTimeUtc();
|
|
444
|
+
if (!SetWaitableTimer(hTimer, ref timerDueTime, 0, IntPtr.Zero, IntPtr.Zero, false))
|
|
445
|
+
throw new InvalidOperationException("SetWaitableTimer failed: " + Marshal.GetLastWin32Error());
|
|
446
|
+
|
|
447
|
+
// Wait for EITHER the process to finish (index 0 - success path
|
|
448
|
+
// below) or the deadline to pass (index 1 - timeout path below).
|
|
449
|
+
// INFINITE is safe here: the timer itself is what bounds this wait,
|
|
450
|
+
// and its due time is already validated into [1, 0xFFFFFFFE] ms.
|
|
451
|
+
// If both handles are ALREADY signaled when the wait is serviced
|
|
452
|
+
// (a child exit racing the deadline - or a deadline that passed
|
|
453
|
+
// during sleep, after which the timer sits signaled while a woken
|
|
454
|
+
// child races through its last instructions),
|
|
455
|
+
// WaitForMultipleObjects reports the LOWEST signaled index: the
|
|
456
|
+
// process. That alone proves nothing about which signal came
|
|
457
|
+
// first, so the tie is resolved below against the timer's
|
|
458
|
+
// absolute due time, not by array order.
|
|
459
|
+
uint waitResult = WaitForMultipleObjects(2, new IntPtr[] { hProcess, hTimer }, false, 0xFFFFFFFF);
|
|
460
|
+
if (waitResult == 0xFFFFFFFF)
|
|
461
|
+
{
|
|
462
|
+
// WAIT_FAILED - same handling as every other launcher: the
|
|
463
|
+
// child's actual state is unknown here - don't just report
|
|
464
|
+
// failure and potentially leave it running unmanaged in the
|
|
465
|
+
// background. Best-effort kill before giving up.
|
|
466
|
+
int waitErr = Marshal.GetLastWin32Error();
|
|
467
|
+
string message = "WaitForMultipleObjects failed: " + waitErr;
|
|
468
|
+
// Report if the best-effort kill itself also failed.
|
|
469
|
+
if (!TerminateProcess(hProcess, 1))
|
|
470
|
+
message += "; TerminateProcess also failed: " + Marshal.GetLastWin32Error();
|
|
471
|
+
throw new InvalidOperationException(message);
|
|
472
|
+
}
|
|
473
|
+
if (waitResult == 0x00000000) // WAIT_OBJECT_0: the process handle signaled
|
|
474
|
+
{
|
|
475
|
+
// The lowest-index rule makes "process reported first"
|
|
476
|
+
// compatible with "exited AFTER the deadline": both objects
|
|
477
|
+
// signaled, process listed first. The kernel's own record of
|
|
478
|
+
// when the process actually exited is the authoritative
|
|
479
|
+
// arbiter - GetProcessTimes returns a real exit time for a
|
|
480
|
+
// terminated process, in the same absolute FILETIME clock and
|
|
481
|
+
// epoch the timer's due time was computed in. At or before
|
|
482
|
+
// the due time: genuinely finished in time, normal success
|
|
483
|
+
// path below. Strictly after: the process only won the
|
|
484
|
+
// array-order tie - treat it exactly like the timer signaling
|
|
485
|
+
// (timeout path below), never as an on-time success.
|
|
486
|
+
FILETIME creationTime, exitTime, kernelTime, userTime;
|
|
487
|
+
if (!GetProcessTimes(hProcess, out creationTime, out exitTime, out kernelTime, out userTime))
|
|
488
|
+
throw new InvalidOperationException("GetProcessTimes failed: " + Marshal.GetLastWin32Error());
|
|
489
|
+
long exitFileTime = ((long)exitTime.dwHighDateTime << 32) | (long)exitTime.dwLowDateTime;
|
|
490
|
+
if (exitFileTime > timerDueTime)
|
|
491
|
+
waitResult = 0x00000001;
|
|
492
|
+
}
|
|
493
|
+
if (waitResult == 0x00000001) // deadline: timer signaled, or the tie-break demoted a post-deadline exit
|
|
494
|
+
{
|
|
495
|
+
// The deadline passed (timer signaled, or the process's real
|
|
496
|
+
// exit time landed after the due time) - the whole point of
|
|
497
|
+
// this tool. Kill the
|
|
498
|
+
// entire job now (see TerminateJobObject above for why one
|
|
499
|
+
// kernel call is the right primitive). KILL_ON_JOB_CLOSE is
|
|
500
|
+
// only the backstop for THIS wrapper dying non-cooperatively;
|
|
501
|
+
// on this path the wrapper is alive and kills the job itself.
|
|
502
|
+
//
|
|
503
|
+
// 124: the unix timeout(1) convention, reported by the
|
|
504
|
+
// PowerShell handler below. Deliberately NOT
|
|
505
|
+
// GetExitCodeProcess here - the reason for exiting is already
|
|
506
|
+
// known, and the process may still be mid-death when asked.
|
|
507
|
+
if (!TerminateJobObject(hJob, 124))
|
|
508
|
+
throw new InvalidOperationException("TerminateJobObject failed: " + Marshal.GetLastWin32Error());
|
|
509
|
+
throw new TimeoutException("caps: timed out after " + timeoutMs + " ms");
|
|
510
|
+
}
|
|
511
|
+
// WAIT_OBJECT_0: the child finished inside the deadline - fall
|
|
512
|
+
// through to the exact same GetExitCodeProcess/propagate path as
|
|
513
|
+
// every other launcher.
|
|
514
|
+
|
|
515
|
+
uint exitCode;
|
|
516
|
+
if (!GetExitCodeProcess(hProcess, out exitCode))
|
|
517
|
+
throw new InvalidOperationException("GetExitCodeProcess failed: " + Marshal.GetLastWin32Error());
|
|
518
|
+
|
|
519
|
+
// WAIT_OBJECT_0 path only: the root process finished inside the
|
|
520
|
+
// deadline - release the kill-on-close backstop so the finally
|
|
521
|
+
// below closes hJob WITHOUT terminating anything still assigned
|
|
522
|
+
// to the job (the documented daemon-survival contract: whatever
|
|
523
|
+
// the command left running detached outlives this wrapper's
|
|
524
|
+
// handle, uncapped by design - caps imposes no resource limit).
|
|
525
|
+
// NOT reached by the timeout path above: that one kills the job
|
|
526
|
+
// itself via TerminateJobObject and keeps the flag as the
|
|
527
|
+
// non-cooperative-death backstop. Deliberately best-effort, not a
|
|
528
|
+
// throw: the wrapped command succeeded, and a failed cleanup
|
|
529
|
+
// syscall must not turn its real exit code below into a wrapper
|
|
530
|
+
// error - warn on stderr instead.
|
|
531
|
+
var releaseInfo = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION
|
|
532
|
+
{
|
|
533
|
+
BasicLimitInformation = new JOBOBJECT_BASIC_LIMIT_INFORMATION
|
|
534
|
+
{
|
|
535
|
+
LimitFlags = 0
|
|
536
|
+
}
|
|
537
|
+
};
|
|
538
|
+
int releaseSize = Marshal.SizeOf(releaseInfo);
|
|
539
|
+
IntPtr releasePtr = Marshal.AllocHGlobal(releaseSize);
|
|
540
|
+
bool releaseOk;
|
|
541
|
+
int releaseErr = 0;
|
|
542
|
+
try
|
|
543
|
+
{
|
|
544
|
+
Marshal.StructureToPtr(releaseInfo, releasePtr, false);
|
|
545
|
+
releaseOk = SetInformationJobObject(hJob, JobObjectExtendedLimitInformation, releasePtr, (uint)releaseSize);
|
|
546
|
+
// Capture the Win32 error immediately, before any other call can
|
|
547
|
+
// overwrite it - every other native failure branch in this file
|
|
548
|
+
// reports the code for the same diagnosability reason.
|
|
549
|
+
if (!releaseOk)
|
|
550
|
+
releaseErr = Marshal.GetLastWin32Error();
|
|
551
|
+
}
|
|
552
|
+
finally
|
|
553
|
+
{
|
|
554
|
+
Marshal.FreeHGlobal(releasePtr);
|
|
555
|
+
}
|
|
556
|
+
if (!releaseOk)
|
|
557
|
+
Console.Error.WriteLine("warning: could not release the job's kill-on-close guard (SetInformationJobObject failed with Win32 error " + releaseErr + ") - a still-running background process left by the wrapped command may be terminated when this wrapper exits");
|
|
558
|
+
|
|
559
|
+
return (int)exitCode;
|
|
560
|
+
}
|
|
561
|
+
finally
|
|
562
|
+
{
|
|
563
|
+
// Thread handle, process handle, job handle, then the timer. The
|
|
564
|
+
// timer's position is functionally irrelevant - an anonymous kernel
|
|
565
|
+
// object with no cascade semantics, unlike hJob whose close IS the
|
|
566
|
+
// kill-on-close trigger - so it is appended last to leave the
|
|
567
|
+
// long-established sequence untouched.
|
|
568
|
+
if (hThread != IntPtr.Zero) CloseHandle(hThread);
|
|
569
|
+
if (hProcess != IntPtr.Zero) CloseHandle(hProcess);
|
|
570
|
+
if (hJob != IntPtr.Zero) CloseHandle(hJob);
|
|
571
|
+
if (hTimer != IntPtr.Zero) CloseHandle(hTimer);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
"@
|
|
576
|
+
|
|
577
|
+
Add-Type -TypeDefinition $source -Language CSharp
|
|
578
|
+
|
|
579
|
+
try {
|
|
580
|
+
exit ([CapsLauncher]::Run($timeoutMs, [string[]]$Command, $commandLine))
|
|
581
|
+
} catch [System.TimeoutException] {
|
|
582
|
+
# $secondsArg is the user's own spelling of the deadline ("2", "2.5") -
|
|
583
|
+
# echo that, not a re-derived number, and exit with timeout(1)'s 124.
|
|
584
|
+
Write-Error "caps: timed out after ${secondsArg}s - job and every process in it were force-killed"
|
|
585
|
+
exit 124
|
|
586
|
+
} catch {
|
|
587
|
+
Write-Error $_.Exception.InnerException.Message
|
|
588
|
+
exit 1
|
|
589
|
+
}
|
package/bin/capt
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# SPDX-License-Identifier: MIT OR Apache-2.0
|
|
3
|
+
# win-nice: managed-file
|
|
4
|
+
# Git Bash ignores PATHEXT for bare-name resolution; this shim covers that shell.
|
|
5
|
+
# MSYS2_ARG_CONV_EXCL='*' stops Git Bash/MSYS from rewriting user arguments
|
|
6
|
+
# (e.g. /c, /d, C:\...) into Windows paths before the exec; the shim's own
|
|
7
|
+
# .ps1 path is converted explicitly with cygpath -w so -File gets a Windows path.
|
|
8
|
+
MSYS2_ARG_CONV_EXCL='*'
|
|
9
|
+
export MSYS2_ARG_CONV_EXCL
|
|
10
|
+
script=$(cygpath -w "$(dirname "$0")/capt.ps1" 2>/dev/null) || script="$(dirname "$0")/capt.ps1"
|
|
11
|
+
exec powershell -NoProfile -ExecutionPolicy Bypass -File "$script" "$@"
|
package/bin/capt.bat
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
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 capc.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 "capt"
|
|
7
|
+
:: bare from an actual PowerShell session skips this file (capt.ps1 preferred).
|
|
8
|
+
"%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File "%~dp0capt.ps1" %*
|