@rightkit/release 0.2.50 → 0.2.52

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/build-release.mjs CHANGED
@@ -27,7 +27,7 @@ import { createTargetBridge } from "./target-bridge.mjs";
27
27
  import { assertNsisInPlaceUpgradeContract } from "./nsis-upgrade-contract.mjs";
28
28
  import { buildPathPrefix, collectPreflight, formatPreflight, preflightFailures } from "./preflight.mjs";
29
29
  import { assertCleanSource } from "./source-gate.mjs";
30
- import { acquireHeavyWorkSlot, heavyCommandEnvironment } from "./heavy-command.mjs";
30
+ import { acquireHeavyWorkSlot, heavyCommandEnvironment, terminateProcessTree } from "./heavy-command.mjs";
31
31
 
32
32
  /** Assemble preflight inputs from the app's own files (mirrors release.mjs). */
33
33
  function buildPreflight({ config, configPath, appRoot, repoRoot, platform }) {
@@ -364,7 +364,16 @@ async function runProgress(cmd, runArgs, cwd, env, watchDir) {
364
364
  let lastMtime = Math.max(...watchDirs.map(newestMtime));
365
365
  const started = Date.now();
366
366
  await new Promise((resolve, reject) => {
367
- child = spawn(cmd, runArgs, { cwd, env, stdio: ["inherit", "pipe", "pipe"], windowsHide: true, shell: process.platform === "win32" });
367
+ child = spawn(cmd, runArgs, { cwd, env, stdio: ["inherit", "pipe", "pipe"], windowsHide: true, shell: process.platform === "win32", detached: process.platform !== "win32" });
368
+ try {
369
+ if (child.pid && heavySlot) heavySlot.trackChild(child.pid, cmd);
370
+ } catch (error) {
371
+ child.once("error", () => {});
372
+ if (child.pid) killTree(child.pid);
373
+ child = null;
374
+ reject(error);
375
+ return;
376
+ }
368
377
  const closeWatchers = watchProgress(watchDirs, () => { lastProgress = Date.now(); });
369
378
  for (const [stream, output] of [[child.stdout, process.stdout], [child.stderr, process.stderr]]) {
370
379
  stream.on("data", (chunk) => { lastProgress = Date.now(); output.write(chunk); });
@@ -464,9 +473,7 @@ function runChecked(cmd, runArgs, cwd, env = process.env) {
464
473
  }
465
474
 
466
475
  function killTree(pid) {
467
- if (!pid) return;
468
- if (process.platform === "win32") spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
469
- else { try { process.kill(-pid, "SIGTERM"); } catch { try { process.kill(pid, "SIGTERM"); } catch { /* gone */ } } }
476
+ terminateProcessTree(pid);
470
477
  }
471
478
 
472
479
  function writeJson(file, value) {
@@ -16,6 +16,8 @@ test("macOS and Windows builds default to Cache V2 while explicit legacy mode re
16
16
  assert.match(source, /acquireSuiteBuildSlot/);
17
17
  assert.match(source, /acquireHeavyWorkSlot/);
18
18
  assert.match(source, /heavyCommandEnvironment/);
19
+ assert.match(source, /heavySlot\.trackChild/);
20
+ assert.match(source, /detached: process\.platform !== "win32"/);
19
21
  assert.match(source, /acquireCacheLease/);
20
22
  assert.match(source, /markCacheEntrySuccessful/);
21
23
  assert.match(source, /RIGHT_RELEASE_CACHE_OWNER/);
@@ -16,6 +16,10 @@ export function isolatedCargoMetadataEnv(cargoHome, env = process.env) {
16
16
  return { ...metadataEnv, CARGO_HOME: cargoHome };
17
17
  }
18
18
 
19
+ export function cargoExecutable(platform = process.platform) {
20
+ return platform === "win32" ? "cargo.exe" : "cargo";
21
+ }
22
+
19
23
  export function validateRightKitCargoContract(root, publishedVersions, label = path.basename(root)) {
20
24
  const scanRoot = path.resolve(root);
21
25
  const boundary = findRepositoryRoot(scanRoot);
@@ -72,7 +76,7 @@ export function assertNoRightKitCargoOverrides(manifestPath, repoRoot, label) {
72
76
 
73
77
  function readCargoManifestDependencies(manifestPath, cargoHome, label) {
74
78
  const result = spawnSync(
75
- "cargo",
79
+ cargoExecutable(),
76
80
  ["metadata", "--no-deps", "--offline", "--format-version", "1", "--manifest-path", manifestPath],
77
81
  {
78
82
  cwd: path.dirname(manifestPath),
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ import { spawn, spawnSync } from "node:child_process";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ import { runHeavyCommand } from "./heavy-command.mjs";
7
+
8
+ const LIGHT_COMMANDS = new Set(["fmt", "metadata", "tree", "fetch", "search", "locate-project", "read-manifest", "help", "version", "--version", "-V"]);
9
+ const GLOBAL_OPTIONS_WITH_VALUE = new Set(["--color", "--config", "-Z"]);
10
+
11
+ export function cargoSubcommand(args) {
12
+ for (let index = 0; index < args.length; index += 1) {
13
+ const value = args[index];
14
+ if (value.startsWith("+") || value.startsWith("--color=") || value.startsWith("--config=")) continue;
15
+ if (GLOBAL_OPTIONS_WITH_VALUE.has(value)) { index += 1; continue; }
16
+ if (value.startsWith("-Z")) continue;
17
+ if (!value.startsWith("-") || value === "--version" || value === "-V") return value;
18
+ }
19
+ return null;
20
+ }
21
+
22
+ export function shouldGuardCargo(args) {
23
+ const command = cargoSubcommand(args);
24
+ return command !== null && !LIGHT_COMMANDS.has(command);
25
+ }
26
+
27
+ export function resolveRealCargo({ env = process.env, run = spawnSync } = {}) {
28
+ if (env.RIGHTSUITE_REAL_CARGO) return path.resolve(env.RIGHTSUITE_REAL_CARGO);
29
+ const rustup = process.platform === "win32" ? "rustup.exe" : "rustup";
30
+ const result = run(rustup, ["which", "cargo"], { encoding: "utf8", windowsHide: true });
31
+ const cargo = String(result.stdout ?? "").trim();
32
+ if (result.status !== 0 || !cargo) throw new Error(`cargo-guard could not resolve real Cargo: ${String(result.stderr ?? "").trim()}`);
33
+ return path.resolve(cargo);
34
+ }
35
+
36
+ function spawnCargo(command, args, env) {
37
+ return new Promise((resolve, reject) => {
38
+ const child = spawn(command, args, { env, stdio: "inherit", windowsHide: true });
39
+ child.once("error", reject);
40
+ child.once("exit", (code, signal) => resolve(code ?? (signal ? 1 : 0)));
41
+ });
42
+ }
43
+
44
+ export async function runCargoGuard(args, { env = process.env, resolveCargo = resolveRealCargo, runHeavy = runHeavyCommand, runLight = spawnCargo } = {}) {
45
+ const cargo = resolveCargo({ env });
46
+ if (shouldGuardCargo(args)) return runHeavy(["--", cargo, ...args], { env });
47
+ return runLight(cargo, args, env);
48
+ }
49
+
50
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
51
+ runCargoGuard(process.argv.slice(2)).then((code) => { process.exitCode = code; }).catch((error) => { console.error(`cargo-guard: ${error.message}`); process.exitCode = 1; });
52
+ }
@@ -0,0 +1,39 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import { cargoSubcommand, resolveRealCargo, runCargoGuard, shouldGuardCargo } from "./cargo-guard.mjs";
5
+
6
+ test("Cargo guard serializes compiling commands but bypasses inspection and formatting", () => {
7
+ for (const args of [["build"], ["test"], ["check"], ["clippy"], ["nextest", "run"], ["clean"], ["+stable", "bench"]]) {
8
+ assert.equal(shouldGuardCargo(args), true, args.join(" "));
9
+ }
10
+ for (const args of [["fmt", "--check"], ["metadata"], ["tree"], ["fetch"], ["--version"], []]) {
11
+ assert.equal(shouldGuardCargo(args), false, args.join(" "));
12
+ }
13
+ assert.equal(cargoSubcommand(["+stable", "test"]), "test");
14
+ assert.equal(shouldGuardCargo(["--color", "always", "fmt"]), false);
15
+ assert.equal(shouldGuardCargo(["--config", "net.git-fetch-with-cli=true", "check"]), true);
16
+ assert.equal(shouldGuardCargo(["-Z", "unstable-options", "fmt"]), false);
17
+ });
18
+
19
+ test("Cargo guard resolves real Cargo through rustup or explicit override", () => {
20
+ assert.equal(resolveRealCargo({ env: { RIGHTSUITE_REAL_CARGO: "/opt/toolchain/cargo" } }), "/opt/toolchain/cargo");
21
+ const calls = [];
22
+ const cargo = resolveRealCargo({ env: {}, run: (command, args) => { calls.push([command, args]); return { status: 0, stdout: "/real/cargo\n" }; } });
23
+ assert.equal(cargo, "/real/cargo");
24
+ assert.deepEqual(calls[0][1], ["which", "cargo"]);
25
+ });
26
+
27
+ test("Cargo guard routes heavy and light commands without recursion", async () => {
28
+ const calls = [];
29
+ const options = {
30
+ env: { TEST: "1" },
31
+ resolveCargo: () => "/real/cargo",
32
+ runHeavy: async (args, config) => { calls.push(["heavy", args, config.env]); return 0; },
33
+ runLight: async (command, args, env) => { calls.push(["light", command, args, env]); return 0; },
34
+ };
35
+ assert.equal(await runCargoGuard(["test"], options), 0);
36
+ assert.equal(await runCargoGuard(["fmt", "--check"], options), 0);
37
+ assert.deepEqual(calls[0], ["heavy", ["--", "/real/cargo", "test"], options.env]);
38
+ assert.deepEqual(calls[1], ["light", "/real/cargo", ["fmt", "--check"], options.env]);
39
+ });
package/heavy-command.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { spawn, spawnSync } from "node:child_process";
@@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url";
7
7
  import { randomUUID } from "node:crypto";
8
8
 
9
9
  const GB = 1024 ** 3;
10
+ const SELF = fileURLToPath(import.meta.url);
10
11
 
11
12
  export function heavyWorkRoot({ platform = process.platform, env = process.env, home = os.homedir() } = {}) {
12
13
  if (env.RIGHTSUITE_HEAVY_WORK_ROOT) return path.resolve(env.RIGHTSUITE_HEAVY_WORK_ROOT);
@@ -27,6 +28,10 @@ export function heavyCommandEnvironment(env = process.env) {
27
28
  };
28
29
  }
29
30
 
31
+ export function heavyChildDetached({ slot, platform = process.platform } = {}) {
32
+ return Boolean(slot) && platform !== "win32" && platform !== "win";
33
+ }
34
+
30
35
  export function parseMacResourceSnapshot({ memory = "", swap = "", thermal = "" } = {}) {
31
36
  const freePercent = Number(memory.match(/memory free percentage:\s*(\d+(?:\.\d+)?)%/i)?.[1]);
32
37
  const swapMatch = swap.match(/total\s*=\s*([\d.]+)M\s+used\s*=\s*([\d.]+)M/i);
@@ -103,7 +108,11 @@ export function formatResourceSnapshot(snapshot) {
103
108
 
104
109
  function processAlive(pid) {
105
110
  if (!Number.isInteger(Number(pid)) || Number(pid) <= 0) return false;
106
- try { process.kill(Number(pid), 0); return true; } catch { return false; }
111
+ try { process.kill(Number(pid), 0); return true; } catch (error) { return error?.code === "EPERM"; }
112
+ }
113
+
114
+ function processGroupAlive(pid) {
115
+ try { process.kill(-Number(pid), 0); return true; } catch (error) { return error?.code === "EPERM"; }
107
116
  }
108
117
 
109
118
  function sleep(ms) {
@@ -111,7 +120,74 @@ function sleep(ms) {
111
120
  }
112
121
 
113
122
  function readOwner(lockDir) {
114
- try { return JSON.parse(readFileSync(path.join(lockDir, "owner.json"), "utf8")); } catch { return null; }
123
+ try {
124
+ const owner = JSON.parse(readFileSync(path.join(lockDir, "owner.json"), "utf8"));
125
+ try { return { ...owner, ...JSON.parse(readFileSync(path.join(lockDir, "child.json"), "utf8")) }; } catch { return owner; }
126
+ } catch { return null; }
127
+ }
128
+
129
+ function writeTrackedChild(lockDir, owner, child) {
130
+ const file = path.join(lockDir, "child.json");
131
+ const temporary = path.join(lockDir, `.child-${owner.token}.tmp`);
132
+ writeFileSync(temporary, `${JSON.stringify(child)}\n`, { mode: 0o600 });
133
+ renameSync(temporary, file);
134
+ }
135
+
136
+ export function processStartedAt(pid, { platform = process.platform, run = spawnSync } = {}) {
137
+ if (!Number.isInteger(Number(pid)) || Number(pid) <= 0) return null;
138
+ const result = platform === "win32" || platform === "win"
139
+ ? run("powershell.exe", ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", `(Get-Process -Id ${Number(pid)} -ErrorAction Stop).StartTime.ToUniversalTime().ToString('o')`], { encoding: "utf8", windowsHide: true })
140
+ : run("ps", ["-p", String(pid), "-o", "lstart="], { encoding: "utf8", windowsHide: true });
141
+ if (result.status !== 0) return null;
142
+ const value = Date.parse(String(result.stdout ?? "").trim());
143
+ return Number.isFinite(value) ? value : null;
144
+ }
145
+
146
+ export function terminateProcessTree(pid, { platform = process.platform, run = spawnSync, kill = process.kill, treeAlive = processGroupAlive, pause = sleep } = {}) {
147
+ if (!Number.isInteger(Number(pid)) || Number(pid) <= 0) return;
148
+ if (platform === "win32" || platform === "win") {
149
+ run("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
150
+ return;
151
+ }
152
+ try { kill(-Number(pid), "SIGTERM"); } catch { try { kill(Number(pid), "SIGTERM"); } catch { return; } }
153
+ pause(500);
154
+ if (treeAlive(pid)) { try { kill(-Number(pid), "SIGKILL"); } catch { try { kill(Number(pid), "SIGKILL"); } catch { /* gone */ } } }
155
+ }
156
+
157
+ export async function watchOwnedProcessTree(
158
+ { root, token, ownerPid, childPid, childStartedAtMs },
159
+ { alive = processAlive, startedAt = processStartedAt, terminate = terminateProcessTree, pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) } = {},
160
+ ) {
161
+ const lockDir = path.join(root, "slot");
162
+ while (alive(ownerPid)) {
163
+ if (readOwner(lockDir)?.token !== token) return false;
164
+ await pause(500);
165
+ }
166
+ const owner = readOwner(lockDir);
167
+ if (owner?.token !== token || Number(owner.childPid) !== Number(childPid)) return false;
168
+ if (alive(childPid)) {
169
+ const observedStart = startedAt(childPid);
170
+ if (!Number.isFinite(observedStart) || Math.abs(observedStart - Number(childStartedAtMs)) > 2000) {
171
+ throw new Error(`watcher found reused child pid ${childPid}; refusing termination`);
172
+ }
173
+ terminate(childPid);
174
+ }
175
+ releaseOwned(lockDir, token);
176
+ return true;
177
+ }
178
+
179
+ function spawnOwnerWatcher(details) {
180
+ const payload = Buffer.from(JSON.stringify(details)).toString("base64url");
181
+ const watcher = spawn(process.execPath, [SELF, "--watch-owner-base64", payload], {
182
+ detached: true, env: process.env, stdio: "ignore", windowsHide: true,
183
+ });
184
+ watcher.once("error", () => {
185
+ const observedStart = processStartedAt(details.childPid);
186
+ if (Number.isFinite(observedStart) && Math.abs(observedStart - Number(details.childStartedAtMs)) <= 2000) {
187
+ terminateProcessTree(details.childPid);
188
+ }
189
+ });
190
+ watcher.unref();
115
191
  }
116
192
 
117
193
  export function acquireHeavyWorkSlot({
@@ -119,6 +195,7 @@ export function acquireHeavyWorkSlot({
119
195
  pollMs = 1000, alive = processAlive, pause = sleep, snapshot = systemResourceSnapshot,
120
196
  minFreePercent = Number(process.env.BUILD_GUARD_MIN_MEMORY_FREE_PERCENT ?? 15), log = (message) => console.error(`[heavy-work] ${message}`),
121
197
  maxCpuLoadPercent = Number(process.env.BUILD_GUARD_MAX_CPU_LOAD_PERCENT ?? 90),
198
+ processStartedAt: startedAt = processStartedAt, terminate = terminateProcessTree, startWatcher = spawnOwnerWatcher,
122
199
  } = {}) {
123
200
  mkdirSync(root, { recursive: true });
124
201
  const lockDir = path.join(root, "slot");
@@ -137,6 +214,14 @@ export function acquireHeavyWorkSlot({
137
214
  const owner = readOwner(lockDir);
138
215
  const incompleteAge = (() => { try { return Date.now() - statSync(lockDir).mtimeMs; } catch { return 0; } })();
139
216
  if (owner ? !alive(owner.pid) : incompleteAge > 10_000) {
217
+ if (owner?.childPid && alive(owner.childPid)) {
218
+ const observedStart = startedAt(owner.childPid);
219
+ if (!Number.isFinite(observedStart) || Math.abs(observedStart - Number(owner.childStartedAtMs)) > 2000) {
220
+ throw new Error(`dead heavy-work owner pid ${owner.pid}; tracked child pid ${owner.childPid} identity changed; refusing to kill or admit`);
221
+ }
222
+ log(`reaping owned process tree pid ${owner.childPid} after owner pid ${owner.pid} exited`);
223
+ terminate(owner.childPid);
224
+ }
140
225
  rmSync(lockDir, { recursive: true, force: true });
141
226
  continue;
142
227
  }
@@ -160,7 +245,22 @@ export function acquireHeavyWorkSlot({
160
245
  if (Date.now() - lastReport >= 10_000) { log(`resource wait: ${blockers.join(", ")}`); lastReport = Date.now(); }
161
246
  pause(Math.min(5000, Math.max(1, waitMs - (Date.now() - started))));
162
247
  }
163
- return { root, release: () => releaseOwned(lockDir, token) };
248
+ return {
249
+ root,
250
+ trackChild(childPid, childCommand = "") {
251
+ const owner = readOwner(lockDir);
252
+ if (owner?.token !== token) throw new Error("heavy-work slot ownership changed before child tracking");
253
+ const childStartedAtMs = startedAt(childPid);
254
+ if (!Number.isFinite(childStartedAtMs)) {
255
+ if (!alive(childPid)) return false;
256
+ throw new Error(`heavy-work could not verify child pid ${childPid} start time`);
257
+ }
258
+ writeTrackedChild(lockDir, owner, { childPid: Number(childPid), childCommand: String(childCommand), childStartedAtMs });
259
+ startWatcher({ root, token, ownerPid: Number(pid), childPid: Number(childPid), childStartedAtMs });
260
+ return true;
261
+ },
262
+ release: () => releaseOwned(lockDir, token),
263
+ };
164
264
  }
165
265
 
166
266
  function releaseOwned(lockDir, token) {
@@ -183,17 +283,35 @@ export async function runHeavyCommand(args, { env = process.env } = {}) {
183
283
  if (!command) throw new Error("heavy-command: expected -- <command> or --shell-base64 <value>");
184
284
  if (env.RIGHTSUITE_HEAVY_WORK_OWNER === "1") return spawnAttached(command, commandArgs, env);
185
285
  const slot = acquireHeavyWorkSlot();
186
- try { return await spawnAttached(command, commandArgs, heavyCommandEnvironment(env)); } finally { slot.release(); }
286
+ try { return await spawnAttached(command, commandArgs, heavyCommandEnvironment(env), slot); } finally { slot.release(); }
187
287
  }
188
288
 
189
- function spawnAttached(command, args, env) {
289
+ function spawnAttached(command, args, env, slot) {
190
290
  return new Promise((resolve, reject) => {
191
- const child = spawn(command, args, { cwd: process.cwd(), env, stdio: "inherit", windowsHide: true });
192
- child.once("error", reject);
193
- child.once("exit", (code, signal) => resolve(code ?? (signal ? 1 : 0)));
291
+ const child = spawn(command, args, { cwd: process.cwd(), env, stdio: "inherit", windowsHide: true, detached: heavyChildDetached({ slot }) });
292
+ try {
293
+ if (child.pid && slot) slot.trackChild(child.pid, command);
294
+ } catch (error) {
295
+ child.once("error", () => {});
296
+ if (child.pid) terminateProcessTree(child.pid);
297
+ reject(error);
298
+ return;
299
+ }
300
+ const handlers = new Map(["SIGINT", "SIGTERM", "SIGHUP"].map((signal) => [signal, () => {
301
+ if (child.pid) terminateProcessTree(child.pid);
302
+ process.exitCode = signal === "SIGINT" ? 130 : 143;
303
+ }]));
304
+ for (const [signal, handler] of handlers) process.once(signal, handler);
305
+ const cleanup = () => { for (const [signal, handler] of handlers) process.removeListener(signal, handler); };
306
+ child.once("error", (error) => { cleanup(); reject(error); });
307
+ child.once("exit", (code, signal) => { cleanup(); resolve(code ?? (signal ? 1 : 0)); });
194
308
  });
195
309
  }
196
310
 
197
- if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
198
- runHeavyCommand(process.argv.slice(2)).then((code) => { process.exitCode = code; }).catch((error) => { console.error(`heavy-command: ${error.message}`); process.exitCode = 1; });
311
+ if (process.argv[1] && path.resolve(process.argv[1]) === SELF) {
312
+ const args = process.argv.slice(2);
313
+ const task = args[0] === "--watch-owner-base64"
314
+ ? watchOwnedProcessTree(JSON.parse(Buffer.from(args[1] || "", "base64url").toString("utf8"))).then(() => 0)
315
+ : runHeavyCommand(args);
316
+ task.then((code) => { process.exitCode = code; }).catch((error) => { console.error(`heavy-command: ${error.message}`); process.exitCode = 1; });
199
317
  }
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
- import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { mkdtempSync } from "node:fs";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
@@ -8,6 +8,7 @@ import test from "node:test";
8
8
  import {
9
9
  acquireHeavyWorkSlot,
10
10
  formatResourceSnapshot,
11
+ heavyChildDetached,
11
12
  heavyCommandEnvironment,
12
13
  heavyWorkRoot,
13
14
  parseMacResourceSnapshot,
@@ -15,6 +16,8 @@ import {
15
16
  resourceBlockers,
16
17
  runHeavyCommand,
17
18
  systemResourceSnapshot,
19
+ terminateProcessTree,
20
+ watchOwnedProcessTree,
18
21
  } from "./heavy-command.mjs";
19
22
 
20
23
  test("heavy-work root is machine-wide rather than repository-local", () => {
@@ -30,6 +33,12 @@ test("heavy commands default compiler and test concurrency to two", () => {
30
33
  assert.equal(heavyCommandEnvironment({ CARGO_BUILD_JOBS: "3", RUST_TEST_THREADS: "1" }).CARGO_BUILD_JOBS, "3");
31
34
  });
32
35
 
36
+ test("only the slot owner creates a new POSIX process group", () => {
37
+ assert.equal(heavyChildDetached({ slot: {}, platform: "mac" }), true);
38
+ assert.equal(heavyChildDetached({ slot: null, platform: "mac" }), false);
39
+ assert.equal(heavyChildDetached({ slot: {}, platform: "win" }), false);
40
+ });
41
+
33
42
  test("mac resource parser reports memory, swap and thermal limiting", () => {
34
43
  const snapshot = parseMacResourceSnapshot({
35
44
  memory: "System-wide memory free percentage: 9%",
@@ -76,6 +85,15 @@ test("nested heavy command preserves ownership without another slot", async () =
76
85
  assert.equal(code, 0);
77
86
  });
78
87
 
88
+ test("owned process-tree termination targets a POSIX group or Windows task tree", () => {
89
+ const signals = [];
90
+ terminateProcessTree(202, { platform: "mac", kill: (pid, signal) => signals.push([pid, signal]), treeAlive: () => true, pause: () => {} });
91
+ assert.deepEqual(signals, [[-202, "SIGTERM"], [-202, "SIGKILL"]]);
92
+ const calls = [];
93
+ terminateProcessTree(303, { platform: "win", run: (command, args) => calls.push([command, args]) });
94
+ assert.deepEqual(calls, [["taskkill", ["/PID", "303", "/T", "/F"]]]);
95
+ });
96
+
79
97
  test("slot serializes contenders and removes stale owners", () => {
80
98
  const root = mkdtempSync(path.join(os.tmpdir(), "rightsuite-heavy-"));
81
99
  const quiet = () => {};
@@ -93,3 +111,54 @@ test("slot serializes contenders and removes stale owners", () => {
93
111
  const replacement = acquireHeavyWorkSlot({ root, pid: 404, alive: () => false, snapshot, log: quiet });
94
112
  replacement.release();
95
113
  });
114
+
115
+ test("dead slot owner reaps its recorded matching child tree", () => {
116
+ const root = mkdtempSync(path.join(os.tmpdir(), "rightsuite-heavy-owned-"));
117
+ const killed = [];
118
+ const options = {
119
+ root, snapshot: () => ({ freePercent: 50, thermalLimited: false }), log: () => {},
120
+ alive: (pid) => pid === 202,
121
+ processStartedAt: (pid) => pid === 202 ? 1234 : null,
122
+ terminate: (pid) => killed.push(pid),
123
+ startWatcher: () => {},
124
+ };
125
+ const first = acquireHeavyWorkSlot({ ...options, pid: 101 });
126
+ first.trackChild(202, "cargo");
127
+ assert.equal(JSON.parse(readFileSync(path.join(root, "slot", "child.json"), "utf8")).childPid, 202);
128
+ const replacement = acquireHeavyWorkSlot({ ...options, pid: 404 });
129
+ assert.deepEqual(killed, [202]);
130
+ replacement.release();
131
+ });
132
+
133
+ test("dead slot owner refuses to kill a reused child PID", () => {
134
+ const root = mkdtempSync(path.join(os.tmpdir(), "rightsuite-heavy-reused-"));
135
+ let observedStart = 1234;
136
+ const options = {
137
+ root, snapshot: () => ({ freePercent: 50, thermalLimited: false }), log: () => {},
138
+ alive: (pid) => pid === 202,
139
+ processStartedAt: (pid) => pid === 202 ? observedStart : null,
140
+ terminate: () => assert.fail("reused PID must not be terminated"),
141
+ startWatcher: () => {},
142
+ };
143
+ const first = acquireHeavyWorkSlot({ ...options, pid: 101 });
144
+ first.trackChild(202, "cargo");
145
+ observedStart = 5678;
146
+ assert.throws(() => acquireHeavyWorkSlot({ ...options, pid: 404 }), /identity changed/);
147
+ first.release();
148
+ });
149
+
150
+ test("detached watcher reaps an owned child as soon as its owner disappears", async () => {
151
+ const root = mkdtempSync(path.join(os.tmpdir(), "rightsuite-heavy-watch-"));
152
+ const lockDir = path.join(root, "slot");
153
+ mkdirSync(lockDir);
154
+ writeFileSync(path.join(lockDir, "owner.json"), JSON.stringify({ pid: 101, token: "owned" }));
155
+ writeFileSync(path.join(lockDir, "child.json"), JSON.stringify({ childPid: 202, childStartedAtMs: 1234 }));
156
+ const killed = [];
157
+ const result = await watchOwnedProcessTree(
158
+ { root, token: "owned", ownerPid: 101, childPid: 202, childStartedAtMs: 1234 },
159
+ { alive: (pid) => pid === 202, startedAt: () => 1234, terminate: (pid) => killed.push(pid), pause: async () => {} },
160
+ );
161
+ assert.equal(result, true);
162
+ assert.deepEqual(killed, [202]);
163
+ assert.equal(existsSync(lockDir), false);
164
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/release",
3
- "version": "0.2.50",
3
+ "version": "0.2.52",
4
4
  "description": "Portable Right Suite release CLI/SDK: signed installers, updater artifacts, patch/update routing, hardening, R2 publish, and RightApps registration.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,7 +16,16 @@ function canonical(file) {
16
16
 
17
17
  export function assertPrimaryReleaseCheckout(repoRoot) {
18
18
  const worktrees = git(repoRoot, ["worktree", "list", "--porcelain"]);
19
- const primary = worktrees.match(/^worktree (.+)$/m)?.[1];
19
+ const gitDir = git(repoRoot, ["rev-parse", "--git-dir"]).trim();
20
+ const configured = spawnSync("git", ["config", "--path", "--get", "core.worktree"], {
21
+ cwd: repoRoot, encoding: "utf8", windowsHide: true,
22
+ });
23
+ const primary = resolvePrimaryWorktree({
24
+ repoRoot,
25
+ worktrees,
26
+ gitDir,
27
+ coreWorktree: configured.status === 0 ? configured.stdout.trim() : "",
28
+ });
20
29
  if (!primary) throw new Error("unable to resolve the primary Git worktree");
21
30
  if (canonical(repoRoot) !== canonical(primary)) {
22
31
  throw new Error(`right-release must be invoked from the primary Git worktree: ${primary}`);
@@ -25,6 +34,16 @@ export function assertPrimaryReleaseCheckout(repoRoot) {
25
34
  if (branch.status !== 0) throw new Error("right-release requires a branch-attached primary Git checkout; detached HEAD is forbidden");
26
35
  }
27
36
 
37
+ export function resolvePrimaryWorktree({ repoRoot, worktrees, gitDir, coreWorktree }) {
38
+ const listed = worktrees.match(/^worktree (.+)$/m)?.[1];
39
+ if (!listed) return undefined;
40
+ const resolvedGitDir = path.resolve(repoRoot, gitDir);
41
+ if (path.resolve(listed) === resolvedGitDir && coreWorktree) {
42
+ return path.resolve(resolvedGitDir, coreWorktree);
43
+ }
44
+ return listed;
45
+ }
46
+
28
47
  export function resolveConfiguredBuildInputs(config, target, label = "release config") {
29
48
  const configured = target?.buildInputs ?? config?.buildInputs;
30
49
  if (!Array.isArray(configured?.include) || configured.include.length === 0) {
@@ -0,0 +1,21 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { resolvePrimaryWorktree } from "./release-invocation.mjs";
4
+
5
+ test("resolves a submodule primary worktree from core.worktree", () => {
6
+ assert.equal(resolvePrimaryWorktree({
7
+ repoRoot: "/suite/membrane",
8
+ worktrees: "worktree /suite/.git/modules/membrane\nHEAD abc\n",
9
+ gitDir: "/suite/.git/modules/membrane",
10
+ coreWorktree: "../../../membrane",
11
+ }), "/suite/membrane");
12
+ });
13
+
14
+ test("preserves an ordinary primary worktree", () => {
15
+ assert.equal(resolvePrimaryWorktree({
16
+ repoRoot: "/suite/app",
17
+ worktrees: "worktree /suite/app\nHEAD abc\n",
18
+ gitDir: "/suite/app/.git",
19
+ coreWorktree: "",
20
+ }), "/suite/app");
21
+ });
@@ -8,6 +8,7 @@ import test, { after } from "node:test";
8
8
  import {
9
9
  assertNoRightKitCargoOverrides,
10
10
  assertPublishedRightKitCargoDependencies,
11
+ cargoExecutable,
11
12
  validateRightKitCargoContract,
12
13
  } from "./cargo-contract.mjs";
13
14
  import { assertAsrAdapterPair } from "./asr-artifact-adoption.mjs";
@@ -36,6 +37,13 @@ const apps = [
36
37
  { key: "coderight", root: "coderight/apps/coderight-tauri", tauri: "src-tauri/tauri.conf.json", releaseFiles: ["scripts/mac-dmg.mjs"] },
37
38
  ];
38
39
 
40
+ // ScreenRight is intentionally a macOS-only AppKit consumer. It has no Tauri
41
+ // manifest or Windows target, so keep it in an explicit registry lane rather
42
+ // than manufacturing unsupported platform entries.
43
+ const macOnlyApps = [
44
+ { key: "screenright", root: "tools/screenright", releaseFiles: ["scripts/release-package.mjs"] },
45
+ ];
46
+
39
47
  function assertReleasePackageScripts(scripts, label) {
40
48
  for (const platform of ["mac", "win"]) {
41
49
  assert.equal(scripts[`release:build:${platform}`], `right-release build --platform ${platform}`, `${label} must use the tier-neutral ${platform} build entry point`);
@@ -442,10 +450,10 @@ test("RightKit exposes one current version manifest", () => {
442
450
  "@rightkit/legal": "0.3.0",
443
451
  "@rightkit/legal-ui": "0.1.0",
444
452
  "@rightkit/license": "0.1.6",
445
- "@rightkit/release": "0.2.50",
453
+ "@rightkit/release": "0.2.52",
446
454
  });
447
455
  assert.deepEqual(versions.legacyNpm, {
448
- "@rightkit/release": ["0.2.22", "0.2.29", "0.2.30", "0.2.31", "0.2.41", "0.2.42", "0.2.43", "0.2.44", "0.2.45", "0.2.46", "0.2.49"],
456
+ "@rightkit/release": ["0.2.22", "0.2.29", "0.2.30", "0.2.31", "0.2.41", "0.2.42", "0.2.43", "0.2.44", "0.2.45", "0.2.46", "0.2.49", "0.2.50", "0.2.51"],
449
457
  });
450
458
  assert.ok(
451
459
  new Set([versions.npm["@rightkit/release"], ...versions.legacyNpm["@rightkit/release"]]).has("0.2.42"),
@@ -466,6 +474,11 @@ test("RightKit exposes one current version manifest", () => {
466
474
  getCurrentCargoVersionContract();
467
475
  });
468
476
 
477
+ test("Cargo metadata uses the native Windows executable", () => {
478
+ assert.equal(cargoExecutable("win32"), "cargo.exe");
479
+ assert.equal(cargoExecutable("darwin"), "cargo");
480
+ });
481
+
469
482
  test("license v2 public vector is identical at every portable consumer boundary", () => {
470
483
  const canonical = readFileSync(
471
484
  path.join(workspace, "tools/rightkit/crates/rightkit-license/test-vectors/license-v2.json"),
@@ -608,6 +621,45 @@ for (const app of apps) {
608
621
  });
609
622
  }
610
623
 
624
+ for (const app of macOnlyApps) {
625
+ test(`${app.key} follows the macOS-only AppKit Right Release contract`, async () => {
626
+ const root = path.join(workspace, app.root);
627
+ const pkg = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8"));
628
+ assert.equal(pkg.scripts["release:doctor"], "right-release doctor");
629
+ assert.equal(pkg.scripts["release:build:mac"], "right-release build --platform mac");
630
+ assert.equal(pkg.scripts["release:upload:patch:mac"], "right-release upload --platform mac --tier patch");
631
+ assert.equal(pkg.scripts["release:build:win"], undefined, `${app.key} must not fake a Windows target`);
632
+ assert.equal(pkg.scripts["release:upload:patch:win"], undefined, `${app.key} must not fake a Windows target`);
633
+ assert.equal(pkg.scripts["release:upload:update:mac"], undefined, `${app.key} must not expose ungated feature-update upload`);
634
+ assert.equal(pkg.scripts["release:upload:update:win"], undefined);
635
+
636
+ const config = (await import(`${pathToFileURL(path.join(root, "right-release.config.mjs"))}?mac-only=${Date.now()}`)).default;
637
+ assert.equal(config.app, app.key);
638
+ assert.deepEqual(Object.keys(config.targets), ["mac"]);
639
+ assert.equal(config.targets.mac.signed, true);
640
+ assert.deepEqual(config.targets.mac.publish, {
641
+ cmd: "right-release",
642
+ args: ["publish-update", "--config", "right-release.config.mjs", "--platform", "mac"],
643
+ });
644
+ assert.equal(config.targets.mac.updater.artifacts.length, 1);
645
+ const updater = config.targets.mac.updater.artifacts[0];
646
+ assert.equal(updater.platform, "darwin-aarch64");
647
+ assert.match(updater.file, /\.dmg$/);
648
+ assert.equal(updater.signature, `${updater.file}.sig`);
649
+ assert.equal(updater.key, `${app.key}/updates/mac/current/ScreenRight.dmg`);
650
+ assertBuildInputs(config, config.targets.mac, `${app.key} mac`);
651
+ assertMacPackageEntry(config.targets.mac.package, pkg.scripts, app.key);
652
+ assert.ok(config.targets.mac.installer.artifacts.length >= 1);
653
+ assert.ok(config.checks.includes("legal:check"), `${app.key} must gate generated legal notices`);
654
+ assert.deepEqual(config.deps, { workdirs: ["."] });
655
+ assert.equal(existsSync(path.join(root, "src-tauri", "tauri.conf.json")), false, `${app.key} must not invent a Tauri manifest`);
656
+ for (const releaseFile of app.releaseFiles) {
657
+ const source = readFileSync(path.join(root, releaseFile), "utf8");
658
+ assert.match(source, /mirror-root-artifact/);
659
+ }
660
+ });
661
+ }
662
+
611
663
  test("HeardRight and ScrapeRight expose one locked ASR promotion adapter contract", async () => {
612
664
  const heard = (await import(`${pathToFileURL(path.join(workspace, "heardright/tauri-app-next/right-release.config.mjs"))}?asr=${Date.now()}`)).default;
613
665
  const scrape = (await import(`${pathToFileURL(path.join(workspace, "scraperight/right-release.config.mjs"))}?asr=${Date.now()}`)).default;
@@ -15,10 +15,10 @@
15
15
  "@rightkit/legal": "0.3.0",
16
16
  "@rightkit/legal-ui": "0.1.0",
17
17
  "@rightkit/license": "0.1.6",
18
- "@rightkit/release": "0.2.50"
18
+ "@rightkit/release": "0.2.52"
19
19
  },
20
20
  "legacyNpm": {
21
- "@rightkit/release": ["0.2.22", "0.2.29", "0.2.30", "0.2.31", "0.2.41", "0.2.42", "0.2.43", "0.2.44", "0.2.45", "0.2.46", "0.2.49"]
21
+ "@rightkit/release": ["0.2.22", "0.2.29", "0.2.30", "0.2.31", "0.2.41", "0.2.42", "0.2.43", "0.2.44", "0.2.45", "0.2.46", "0.2.49", "0.2.50", "0.2.51"]
22
22
  },
23
23
  "cargo": {
24
24
  "rightkit-license": "0.1.2",