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.
Files changed (53) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/README.md +309 -41
  3. package/bin/abovenormal +11 -11
  4. package/bin/abovenormal.bat +2 -2
  5. package/bin/abovenormal.ps1 +38 -10
  6. package/bin/admin +11 -11
  7. package/bin/admin.bat +2 -2
  8. package/bin/admin.ps1 +52 -13
  9. package/bin/belownormal +11 -11
  10. package/bin/belownormal.bat +2 -2
  11. package/bin/belownormal.ps1 +38 -10
  12. package/bin/{cap → capc} +11 -11
  13. package/bin/{cap.bat → capc.bat} +3 -3
  14. package/bin/capc.ps1 +432 -0
  15. package/bin/{pint → capm} +11 -11
  16. package/bin/capm.bat +12 -0
  17. package/bin/capm.ps1 +506 -0
  18. package/bin/capn +11 -0
  19. package/bin/capn.bat +8 -0
  20. package/bin/capn.ps1 +426 -0
  21. package/bin/caps +11 -0
  22. package/bin/caps.bat +10 -0
  23. package/bin/caps.ps1 +589 -0
  24. package/bin/capt +11 -0
  25. package/bin/capt.bat +8 -0
  26. package/bin/capt.ps1 +449 -0
  27. package/bin/cx +11 -11
  28. package/bin/cx.bat +1 -1
  29. package/bin/cx.ps1 +38 -10
  30. package/bin/cy +11 -11
  31. package/bin/cy.bat +1 -1
  32. package/bin/cy.ps1 +38 -10
  33. package/bin/high +11 -11
  34. package/bin/high.bat +2 -2
  35. package/bin/high.ps1 +38 -10
  36. package/bin/idle +11 -11
  37. package/bin/idle.bat +2 -2
  38. package/bin/idle.ps1 +38 -10
  39. package/bin/realtime +11 -11
  40. package/bin/realtime.bat +2 -2
  41. package/bin/realtime.ps1 +38 -10
  42. package/bin/uiup +11 -11
  43. package/bin/uiup.bat +1 -1
  44. package/bin/uiup.ps1 +2 -1
  45. package/install/install.js +30 -15
  46. package/install/paths.js +28 -2
  47. package/install/skill.js +25 -1
  48. package/install/uninstall.js +11 -0
  49. package/package.json +7 -3
  50. package/skills/win-nice/SKILL.md +107 -12
  51. package/bin/cap.ps1 +0 -269
  52. package/bin/pint.bat +0 -8
  53. package/bin/pint.ps1 +0 -270
package/bin/capt.ps1 ADDED
@@ -0,0 +1,449 @@
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. "-c" matching "-Count"). Reading
6
+ # everything from $args sidesteps PowerShell's parameter binder entirely.
7
+ $processorCount = [Environment]::ProcessorCount
8
+ $maxCount = [Math]::Min($processorCount, 63)
9
+ if ($args.Count -lt 2) {
10
+ Write-Error "usage: capt <thread-count 1-$maxCount> <command> [args...]"
11
+ exit 1
12
+ }
13
+ $countValue = 0
14
+ if (-not [int]::TryParse($args[0], [ref]$countValue)) {
15
+ Write-Error "usage: capt <thread-count 1-$maxCount> <command> [args...]"
16
+ exit 1
17
+ }
18
+
19
+ # JOBOBJECT_BASIC_LIMIT_INFORMATION.Affinity is a UIntPtr (SIZE_T): it is
20
+ # process-width, so a 32-bit PowerShell process can only express 32 affinity
21
+ # bits. A <thread-count> of 33-63 on a many-core machine passes the machine
22
+ # range check above, then dies inside Run()'s struct initializer with a raw
23
+ # "Arithmetic operation resulted in an overflow." instead of an actionable
24
+ # usage error. Reject it here, capm-style. Pure helper functions rather than
25
+ # inline checks let the Pester suite drive the exact production validation with
26
+ # an injected 32-bit pointer width from a 64-bit process.
27
+ function Get-CaptAffinityBitLimit {
28
+ param([int]$UIntPtrSize = [UIntPtr]::Size)
29
+ return $UIntPtrSize * 8
30
+ }
31
+
32
+ function Get-CaptThreadCountValidation {
33
+ param(
34
+ [int]$Count,
35
+ [int]$MaxCount,
36
+ [int]$AffinityBitLimit,
37
+ [int]$ProcessBitWidth = ([UIntPtr]::Size * 8)
38
+ )
39
+
40
+ if ($Count -lt 1 -or $Count -gt $MaxCount) {
41
+ return [PSCustomObject]@{
42
+ IsValid = $false
43
+ ExitCode = 1
44
+ Message = "usage: capt <thread-count 1-$MaxCount> <command> [args...]"
45
+ }
46
+ }
47
+
48
+ if ($Count -gt $AffinityBitLimit) {
49
+ return [PSCustomObject]@{
50
+ IsValid = $false
51
+ ExitCode = 1
52
+ Message = ("capt: <thread-count> ($Count) exceeds the addressable limit " +
53
+ "for this PowerShell process ($AffinityBitLimit affinity bits, $ProcessBitWidth-bit) - " +
54
+ "use 64-bit PowerShell for counts above $AffinityBitLimit, or lower <thread-count>. " +
55
+ "usage: capt <thread-count 1-$MaxCount> <command> [args...]")
56
+ }
57
+ }
58
+
59
+ return [PSCustomObject]@{
60
+ IsValid = $true
61
+ ExitCode = 0
62
+ Message = $null
63
+ }
64
+ }
65
+
66
+ $validation = Get-CaptThreadCountValidation -Count $countValue -MaxCount $maxCount `
67
+ -AffinityBitLimit (Get-CaptAffinityBitLimit)
68
+ if (-not $validation.IsValid) {
69
+ Write-Error $validation.Message
70
+ exit $validation.ExitCode
71
+ }
72
+ $Command = @($args[1..($args.Count - 1)])
73
+
74
+ # Fallback command line for when the target isn't a directly-launchable .exe (see
75
+ # CaptLauncher.Run below) - re-parsed by cmd.exe (via "cmd.exe /c"), so quoting must
76
+ # neutralize its operators (&|<>^) and not just whitespace - see capc.ps1 for the
77
+ # same logic and its documented "%" limitation.
78
+ $commandLine = ($Command | ForEach-Object {
79
+ $escaped = $_ -replace '"', '\"'
80
+ if ($escaped -eq '' -or $escaped -match '[\s"&|<>^]') { '"' + $escaped + '"' } else { $escaped }
81
+ }) -join ' '
82
+
83
+ $source = @"
84
+ using System;
85
+ using System.Runtime.InteropServices;
86
+ using System.Text;
87
+
88
+ public static class CaptLauncher
89
+ {
90
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
91
+ struct STARTUPINFO
92
+ {
93
+ public int cb;
94
+ public string lpReserved;
95
+ public string lpDesktop;
96
+ public string lpTitle;
97
+ public int dwX;
98
+ public int dwY;
99
+ public int dwXSize;
100
+ public int dwYSize;
101
+ public int dwXCountChars;
102
+ public int dwYCountChars;
103
+ public int dwFillAttribute;
104
+ public int dwFlags;
105
+ public short wShowWindow;
106
+ public short cbReserved2;
107
+ public IntPtr lpReserved2;
108
+ public IntPtr hStdInput;
109
+ public IntPtr hStdOutput;
110
+ public IntPtr hStdError;
111
+ }
112
+
113
+ [StructLayout(LayoutKind.Sequential)]
114
+ struct PROCESS_INFORMATION
115
+ {
116
+ public IntPtr hProcess;
117
+ public IntPtr hThread;
118
+ public int dwProcessId;
119
+ public int dwThreadId;
120
+ }
121
+
122
+ [StructLayout(LayoutKind.Sequential)]
123
+ struct JOBOBJECT_BASIC_LIMIT_INFORMATION
124
+ {
125
+ public long PerProcessUserTimeLimit;
126
+ public long PerJobUserTimeLimit;
127
+ public uint LimitFlags;
128
+ public UIntPtr MinimumWorkingSetSize;
129
+ public UIntPtr MaximumWorkingSetSize;
130
+ public uint ActiveProcessLimit;
131
+ public UIntPtr Affinity;
132
+ public uint PriorityClass;
133
+ public uint SchedulingClass;
134
+ }
135
+
136
+ [StructLayout(LayoutKind.Sequential)]
137
+ struct IO_COUNTERS
138
+ {
139
+ public ulong ReadOperationCount;
140
+ public ulong WriteOperationCount;
141
+ public ulong OtherOperationCount;
142
+ public ulong ReadTransferCount;
143
+ public ulong WriteTransferCount;
144
+ public ulong OtherTransferCount;
145
+ }
146
+
147
+ [StructLayout(LayoutKind.Sequential)]
148
+ struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION
149
+ {
150
+ public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation;
151
+ public IO_COUNTERS IoInfo;
152
+ public UIntPtr ProcessMemoryLimit;
153
+ public UIntPtr JobMemoryLimit;
154
+ public UIntPtr PeakProcessMemoryUsed;
155
+ public UIntPtr PeakJobMemoryUsed;
156
+ }
157
+
158
+ [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
159
+ static extern bool CreateProcess(string lpApplicationName, StringBuilder lpCommandLine,
160
+ IntPtr lpProcessAttributes, IntPtr lpThreadAttributes, bool bInheritHandles,
161
+ uint dwCreationFlags, IntPtr lpEnvironment, string lpCurrentDirectory,
162
+ ref STARTUPINFO lpStartupInfo, out PROCESS_INFORMATION lpProcessInformation);
163
+
164
+ [DllImport("kernel32.dll", SetLastError = true)]
165
+ static extern IntPtr CreateJobObject(IntPtr lpJobAttributes, string lpName);
166
+
167
+ [DllImport("kernel32.dll", SetLastError = true)]
168
+ static extern bool SetInformationJobObject(IntPtr hJob, int JobObjectInfoClass, IntPtr lpJobObjectInfo, uint cbJobObjectInfoLength);
169
+
170
+ [DllImport("kernel32.dll", SetLastError = true)]
171
+ static extern bool AssignProcessToJobObject(IntPtr hJob, IntPtr hProcess);
172
+
173
+ [DllImport("kernel32.dll", SetLastError = true)]
174
+ static extern uint ResumeThread(IntPtr hThread);
175
+
176
+ [DllImport("kernel32.dll", SetLastError = true)]
177
+ static extern uint WaitForSingleObject(IntPtr hHandle, uint dwMilliseconds);
178
+
179
+ [DllImport("kernel32.dll", SetLastError = true)]
180
+ static extern bool GetExitCodeProcess(IntPtr hProcess, out uint lpExitCode);
181
+
182
+ [DllImport("kernel32.dll", SetLastError = true)]
183
+ static extern bool TerminateProcess(IntPtr hProcess, uint uExitCode);
184
+
185
+ [DllImport("kernel32.dll")]
186
+ static extern bool CloseHandle(IntPtr hObject);
187
+
188
+ const uint CREATE_SUSPENDED = 0x00000004;
189
+ const int JobObjectExtendedLimitInformation = 9;
190
+ const uint JOB_OBJECT_LIMIT_AFFINITY = 0x00000010;
191
+ const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000;
192
+
193
+ // Standard MSVCRT/CommandLineToArgvW quoting: safe for a directly-launched .exe's
194
+ // own argv parsing. No cmd.exe involved on this path, so none of its operator or
195
+ // "%" expansion semantics apply - this is the safe path, used whenever possible.
196
+ static string ArgvQuote(string arg)
197
+ {
198
+ if (arg.Length > 0 && arg.IndexOfAny(new char[] { ' ', '\t', '\n', '\v', '"' }) < 0)
199
+ return arg;
200
+
201
+ var result = new StringBuilder();
202
+ result.Append('"');
203
+ int backslashes = 0;
204
+ foreach (char c in arg)
205
+ {
206
+ if (c == '\\')
207
+ {
208
+ backslashes++;
209
+ }
210
+ else if (c == '"')
211
+ {
212
+ result.Append('\\', backslashes * 2 + 1);
213
+ result.Append('"');
214
+ backslashes = 0;
215
+ }
216
+ else
217
+ {
218
+ if (backslashes > 0) { result.Append('\\', backslashes); backslashes = 0; }
219
+ result.Append(c);
220
+ }
221
+ }
222
+ if (backslashes > 0) result.Append('\\', backslashes * 2);
223
+ result.Append('"');
224
+ return result.ToString();
225
+ }
226
+
227
+ static string BuildArgvCommandLine(string[] argv)
228
+ {
229
+ var parts = new string[argv.Length];
230
+ for (int i = 0; i < argv.Length; i++) parts[i] = ArgvQuote(argv[i]);
231
+ return string.Join(" ", parts);
232
+ }
233
+
234
+ public static int Run(ulong affinityMask, string[] argv, string cmdExeCommandLine)
235
+ {
236
+ IntPtr hJob = CreateJobObject(IntPtr.Zero, null);
237
+ if (hJob == IntPtr.Zero)
238
+ throw new InvalidOperationException("CreateJobObject failed: " + Marshal.GetLastWin32Error());
239
+
240
+ // Single owner for every handle this method acquires. The finally below closes
241
+ // hThread/hProcess/hJob - in that order - on EVERY way out: normal return, any
242
+ // of the InvalidOperationExceptions thrown here, and an unexpected managed
243
+ // exception (allocation/marshalling failure) between acquisition and use.
244
+ // hProcess/hThread stay IntPtr.Zero until CreateProcess has actually succeeded,
245
+ // so each handle is closed exactly once and only if it was really acquired.
246
+ IntPtr hProcess = IntPtr.Zero;
247
+ IntPtr hThread = IntPtr.Zero;
248
+ try
249
+ {
250
+ // KILL_ON_JOB_CLOSE: the cleanup in the finally below only runs if this
251
+ // launcher process survives to execute it. Killed from outside (taskkill
252
+ // without /T, a crash), nothing in-process ever runs - without this flag
253
+ // the last job handle dying with the process would leave every process
254
+ // still assigned to the job running on, untracked and unmanaged. With
255
+ // it, Windows itself terminates the whole job at that moment.
256
+ //
257
+ // A backstop only, never the normal exit mechanism: the last handle
258
+ // closing terminates every process still assigned to the job FOR ANY
259
+ // reason, including this wrapper's own orderly close in the finally -
260
+ // and the wait below only waits on the directly wrapped root process,
261
+ // so a daemon it spawned and left running can still be in the job at
262
+ // that point, the root long gone. Killing a daemon on a SUCCESSFUL
263
+ // exit would break the documented daemon-survival contract (README:
264
+ // the affinity limit sticks to any daemon the wrapped command leaves
265
+ // running, for that daemon's whole lifetime), so the success path
266
+ // below clears this flag first - see capc.ps1 for the full write-up.
267
+ // Set via the EXTENDED info class: JobObjectBasicLimitInformation
268
+ // rejects this flag with ERROR_INVALID_PARAMETER.
269
+ var extInfo = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION
270
+ {
271
+ BasicLimitInformation = new JOBOBJECT_BASIC_LIMIT_INFORMATION
272
+ {
273
+ LimitFlags = JOB_OBJECT_LIMIT_AFFINITY | JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
274
+ Affinity = (UIntPtr)affinityMask
275
+ }
276
+ };
277
+ int size = Marshal.SizeOf(extInfo);
278
+ IntPtr ptr = Marshal.AllocHGlobal(size);
279
+ bool ok;
280
+ try
281
+ {
282
+ Marshal.StructureToPtr(extInfo, ptr, false);
283
+ ok = SetInformationJobObject(hJob, JobObjectExtendedLimitInformation, ptr, (uint)size);
284
+ }
285
+ finally
286
+ {
287
+ Marshal.FreeHGlobal(ptr);
288
+ }
289
+ if (!ok)
290
+ throw new InvalidOperationException("SetInformationJobObject failed: " + Marshal.GetLastWin32Error());
291
+
292
+ var si = new STARTUPINFO();
293
+ si.cb = Marshal.SizeOf(si);
294
+ PROCESS_INFORMATION pi = new PROCESS_INFORMATION();
295
+
296
+ // See capc.ps1 for why .bat/.cmd targets skip the direct attempt entirely:
297
+ // CreateProcess silently re-invokes them through cmd.exe on its own, using
298
+ // unescaped text, instead of failing the way a genuinely missing exe would.
299
+ bool isBatOrCmd = argv.Length > 0 && (
300
+ argv[0].EndsWith(".bat", StringComparison.OrdinalIgnoreCase) ||
301
+ argv[0].EndsWith(".cmd", StringComparison.OrdinalIgnoreCase));
302
+
303
+ bool created = false;
304
+ if (!isBatOrCmd)
305
+ {
306
+ var directCommandLine = new StringBuilder(BuildArgvCommandLine(argv));
307
+ created = CreateProcess(null, directCommandLine, IntPtr.Zero, IntPtr.Zero, true,
308
+ CREATE_SUSPENDED, IntPtr.Zero, null, ref si, out pi);
309
+ }
310
+
311
+ if (!created)
312
+ {
313
+ // Falling back to cmd.exe /c: a literal "%" in any argument could now
314
+ // trigger environment-variable expansion (cmd.exe pairs up "%" characters
315
+ // across the whole command line, even across separate arguments) and
316
+ // change what actually runs. Fail loudly here instead of silently risking
317
+ // that - there's no reliable per-character escape for "%" at this level.
318
+ foreach (var a in argv)
319
+ {
320
+ if (a.IndexOf('%') >= 0)
321
+ throw new InvalidOperationException(
322
+ "Refusing to run: argument contains '%' and the target needs the cmd.exe " +
323
+ "fallback (not a directly-launchable .exe), where '%' can trigger unintended " +
324
+ "environment-variable expansion. See README's Argument handling section.");
325
+ }
326
+
327
+ string cmdExe = Environment.SystemDirectory + "\\cmd.exe";
328
+ // /d: skip HKCU AutoRun (user-writable registry key). /v:off: disable delayed
329
+ // expansion so "!var!" in an argument can't be expanded. /s plus the extra outer
330
+ // quote pair: cmd's /S rule strips exactly that outer pair and leaves the rest of
331
+ // the string untouched - without /S, cmd strips the first and last quote of the
332
+ // whole line instead, which breaks quoting whenever the target path itself needs
333
+ // quotes AND another argument is also quoted.
334
+ var shellCommandLine = new StringBuilder("\"" + cmdExe + "\" /d /v:off /s /c \"" + cmdExeCommandLine + "\"");
335
+ created = CreateProcess(null, shellCommandLine, IntPtr.Zero, IntPtr.Zero, true,
336
+ CREATE_SUSPENDED, IntPtr.Zero, null, ref si, out pi);
337
+ if (!created)
338
+ throw new InvalidOperationException("CreateProcess failed: " + Marshal.GetLastWin32Error());
339
+ }
340
+
341
+ // Ownership of the child's handles transfers here, once CreateProcess has
342
+ // actually succeeded - from this point the finally below is what closes them.
343
+ hProcess = pi.hProcess;
344
+ hThread = pi.hThread;
345
+
346
+ if (!AssignProcessToJobObject(hJob, hProcess))
347
+ {
348
+ // Can't guarantee the pin - kill instead of letting it run unpinned and orphaned.
349
+ int err = Marshal.GetLastWin32Error();
350
+ string message = "AssignProcessToJobObject failed: " + err;
351
+ // Report if the best-effort kill itself also failed.
352
+ if (!TerminateProcess(hProcess, 1))
353
+ message += "; TerminateProcess also failed: " + Marshal.GetLastWin32Error();
354
+ throw new InvalidOperationException(message);
355
+ }
356
+
357
+ if (ResumeThread(hThread) == 0xFFFFFFFF)
358
+ {
359
+ // Still suspended - an unbounded wait below would hang forever. Kill
360
+ // it instead of leaving an orphaned, permanently-suspended process.
361
+ int resumeErr = Marshal.GetLastWin32Error();
362
+ string message = "ResumeThread failed: " + resumeErr;
363
+ // Report if the best-effort kill itself also failed.
364
+ if (!TerminateProcess(hProcess, 1))
365
+ message += "; TerminateProcess also failed: " + Marshal.GetLastWin32Error();
366
+ throw new InvalidOperationException(message);
367
+ }
368
+
369
+ if (WaitForSingleObject(hProcess, 0xFFFFFFFF) == 0xFFFFFFFF)
370
+ {
371
+ // The child's actual state is unknown here - don't just report
372
+ // failure and potentially leave it running unmanaged in the
373
+ // background. Best-effort kill before giving up.
374
+ int waitErr = Marshal.GetLastWin32Error();
375
+ string message = "WaitForSingleObject failed: " + waitErr;
376
+ // Report if the best-effort kill itself also failed.
377
+ if (!TerminateProcess(hProcess, 1))
378
+ message += "; TerminateProcess also failed: " + Marshal.GetLastWin32Error();
379
+ throw new InvalidOperationException(message);
380
+ }
381
+
382
+ uint exitCode;
383
+ if (!GetExitCodeProcess(hProcess, out exitCode))
384
+ throw new InvalidOperationException("GetExitCodeProcess failed: " + Marshal.GetLastWin32Error());
385
+
386
+ // Normal success: the root process finished - release the
387
+ // kill-on-close backstop so the finally below closes hJob WITHOUT
388
+ // terminating anything still assigned to the job (the documented
389
+ // daemon-survival contract: the affinity cap keeps applying to
390
+ // whatever the command left running, it just outlives this
391
+ // wrapper's handle). Deliberately best-effort, not a throw: the
392
+ // wrapped command succeeded, and a failed cleanup syscall must not
393
+ // turn its real exit code below into a wrapper error - warn on
394
+ // stderr instead. Same struct and field values as the original
395
+ // set above, minus the kill-on-close bit, so the daemon keeps its
396
+ // affinity pin.
397
+ var releaseInfo = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION
398
+ {
399
+ BasicLimitInformation = new JOBOBJECT_BASIC_LIMIT_INFORMATION
400
+ {
401
+ LimitFlags = JOB_OBJECT_LIMIT_AFFINITY,
402
+ Affinity = (UIntPtr)affinityMask
403
+ }
404
+ };
405
+ int releaseSize = Marshal.SizeOf(releaseInfo);
406
+ IntPtr releasePtr = Marshal.AllocHGlobal(releaseSize);
407
+ bool releaseOk;
408
+ int releaseErr = 0;
409
+ try
410
+ {
411
+ Marshal.StructureToPtr(releaseInfo, releasePtr, false);
412
+ releaseOk = SetInformationJobObject(hJob, JobObjectExtendedLimitInformation, releasePtr, (uint)releaseSize);
413
+ // Capture the Win32 error immediately, before any other call can
414
+ // overwrite it - every other native failure branch in this file
415
+ // reports the code for the same diagnosability reason.
416
+ if (!releaseOk)
417
+ releaseErr = Marshal.GetLastWin32Error();
418
+ }
419
+ finally
420
+ {
421
+ Marshal.FreeHGlobal(releasePtr);
422
+ }
423
+ if (!releaseOk)
424
+ 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");
425
+
426
+ return (int)exitCode;
427
+ }
428
+ finally
429
+ {
430
+ // Same order as the code this replaces: thread handle, process handle, job handle.
431
+ if (hThread != IntPtr.Zero) CloseHandle(hThread);
432
+ if (hProcess != IntPtr.Zero) CloseHandle(hProcess);
433
+ if (hJob != IntPtr.Zero) CloseHandle(hJob);
434
+ }
435
+ }
436
+ }
437
+ "@
438
+
439
+ Add-Type -TypeDefinition $source -Language CSharp
440
+
441
+ # First $countValue logical processors, i.e. threads - not physical cores. See
442
+ # README. Bit-shift, not [Math]::Pow: doubles can't exactly represent 2^63.
443
+ $affinityMask = ([uint64]1 -shl $countValue) - [uint64]1
444
+ try {
445
+ exit ([CaptLauncher]::Run($affinityMask, [string[]]$Command, $commandLine))
446
+ } catch {
447
+ Write-Error $_.Exception.InnerException.Message
448
+ exit 1
449
+ }
package/bin/cx CHANGED
@@ -1,11 +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")/cx.ps1" 2>/dev/null) || script="$(dirname "$0")/cx.ps1"
11
- exec powershell -NoProfile -ExecutionPolicy Bypass -File "$script" "$@"
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")/cx.ps1" 2>/dev/null) || script="$(dirname "$0")/cx.ps1"
11
+ exec powershell -NoProfile -ExecutionPolicy Bypass -File "$script" "$@"
package/bin/cx.bat CHANGED
@@ -1,7 +1,7 @@
1
1
  @echo off
2
2
  :: SPDX-License-Identifier: MIT OR Apache-2.0
3
3
  :: win-nice: managed-file
4
- :: A literal "%" in any argument gets corrupted here - see cap.bat for why (a
4
+ :: A literal "%" in any argument gets corrupted here - see capc.bat for why (a
5
5
  :: cmd.exe batch-parameter quirk, not fixable from inside a .bat). Every other
6
6
  :: cmd.exe metacharacter (&|<>^) survives this hop untouched. Invoking "cx" bare
7
7
  :: from an actual PowerShell session skips this file (cx.ps1 preferred).
package/bin/cx.ps1 CHANGED
@@ -1,12 +1,12 @@
1
1
  # SPDX-License-Identifier: MIT OR Apache-2.0
2
2
  # win-nice: managed-file
3
3
  # No param(): $args sidesteps PowerShell's parameter binder entirely - see
4
- # cap.ps1 for why that matters (codex's own flags shouldn't get bound here).
4
+ # capc.ps1 for why that matters (codex's own flags shouldn't get bound here).
5
5
  $Command = @('codex', '--dangerously-bypass-approvals-and-sandbox') + @($args)
6
6
 
7
7
  # Fallback command line for when codex isn't a directly-launchable .exe (it's
8
8
  # typically an npm-installed .cmd shim on Windows) - see CxLauncher.Run below and
9
- # cap.ps1 for the same logic and its documented "%" limitation. cx.bat has its own,
9
+ # capc.ps1 for the same logic and its documented "%" limitation. cx.bat has its own,
10
10
  # more severe "%" caveat (see there) that applies before this script ever runs.
11
11
  $commandLine = ($Command | ForEach-Object {
12
12
  $escaped = $_ -replace '"', '\"'
@@ -64,6 +64,9 @@ public static class CxLauncher
64
64
  [DllImport("kernel32.dll", SetLastError = true)]
65
65
  static extern bool GetExitCodeProcess(IntPtr hProcess, out uint lpExitCode);
66
66
 
67
+ [DllImport("kernel32.dll", SetLastError = true)]
68
+ static extern bool TerminateProcess(IntPtr hProcess, uint uExitCode);
69
+
67
70
  [DllImport("kernel32.dll")]
68
71
  static extern bool CloseHandle(IntPtr hObject);
69
72
 
@@ -114,7 +117,7 @@ public static class CxLauncher
114
117
  si.cb = Marshal.SizeOf(si);
115
118
  PROCESS_INFORMATION pi = new PROCESS_INFORMATION();
116
119
 
117
- // See cap.ps1 for why .bat/.cmd targets skip the direct attempt entirely:
120
+ // See capc.ps1 for why .bat/.cmd targets skip the direct attempt entirely:
118
121
  // CreateProcess silently re-invokes them through cmd.exe on its own, using
119
122
  // unescaped text, instead of failing the way a genuinely missing exe would.
120
123
  // A bare name like "codex" (typically an npm .cmd shim on Windows) isn't
@@ -139,6 +142,7 @@ public static class CxLauncher
139
142
  // across the whole command line, even across separate arguments) and
140
143
  // change what actually runs. Fail loudly here instead of silently risking
141
144
  // that - there's no reliable per-character escape for "%" at this level.
145
+ // No handle is held at this point, so this throw has nothing to clean up.
142
146
  foreach (var a in argv)
143
147
  {
144
148
  if (a.IndexOf('%') >= 0)
@@ -162,15 +166,39 @@ public static class CxLauncher
162
166
  throw new InvalidOperationException("CreateProcess failed: " + Marshal.GetLastWin32Error());
163
167
  }
164
168
 
165
- WaitForSingleObject(pi.hProcess, 0xFFFFFFFF);
166
-
167
- uint exitCode;
168
- GetExitCodeProcess(pi.hProcess, out exitCode);
169
+ // Ownership of the child's handles starts here - CreateProcess has succeeded, so
170
+ // both are valid, and the finally below closes each of them exactly once on every
171
+ // way out: normal return, a thrown InvalidOperationException, or an unexpected
172
+ // managed exception.
173
+ IntPtr hProcess = pi.hProcess;
174
+ IntPtr hThread = pi.hThread;
175
+ try
176
+ {
177
+ if (WaitForSingleObject(hProcess, 0xFFFFFFFF) == 0xFFFFFFFF)
178
+ {
179
+ // The child's actual state is unknown here - don't just report
180
+ // failure and potentially leave it running unmanaged in the
181
+ // background. Best-effort kill before giving up.
182
+ int waitErr = Marshal.GetLastWin32Error();
183
+ string message = "WaitForSingleObject failed: " + waitErr;
184
+ // Report if the best-effort kill itself also failed.
185
+ if (!TerminateProcess(hProcess, 1))
186
+ message += "; TerminateProcess also failed: " + Marshal.GetLastWin32Error();
187
+ throw new InvalidOperationException(message);
188
+ }
169
189
 
170
- CloseHandle(pi.hThread);
171
- CloseHandle(pi.hProcess);
190
+ uint exitCode;
191
+ if (!GetExitCodeProcess(hProcess, out exitCode))
192
+ throw new InvalidOperationException("GetExitCodeProcess failed: " + Marshal.GetLastWin32Error());
172
193
 
173
- return (int)exitCode;
194
+ return (int)exitCode;
195
+ }
196
+ finally
197
+ {
198
+ // Same order as the code this replaces: thread handle, then process handle.
199
+ CloseHandle(hThread);
200
+ CloseHandle(hProcess);
201
+ }
174
202
  }
175
203
  }
176
204
  "@
package/bin/cy CHANGED
@@ -1,11 +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")/cy.ps1" 2>/dev/null) || script="$(dirname "$0")/cy.ps1"
11
- exec powershell -NoProfile -ExecutionPolicy Bypass -File "$script" "$@"
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")/cy.ps1" 2>/dev/null) || script="$(dirname "$0")/cy.ps1"
11
+ exec powershell -NoProfile -ExecutionPolicy Bypass -File "$script" "$@"
package/bin/cy.bat CHANGED
@@ -1,7 +1,7 @@
1
1
  @echo off
2
2
  :: SPDX-License-Identifier: MIT OR Apache-2.0
3
3
  :: win-nice: managed-file
4
- :: A literal "%" in any argument gets corrupted here - see cap.bat for why (a
4
+ :: A literal "%" in any argument gets corrupted here - see capc.bat for why (a
5
5
  :: cmd.exe batch-parameter quirk, not fixable from inside a .bat). Every other
6
6
  :: cmd.exe metacharacter (&|<>^) survives this hop untouched. Invoking "cy" bare
7
7
  :: from an actual PowerShell session skips this file (cy.ps1 preferred).