@ornncompute/cli 0.2.12 → 0.2.14

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/README.md CHANGED
@@ -371,6 +371,20 @@ If an installation fails, Ornn continues only when it can verify that
371
371
  the remaining CLI loads successfully. Otherwise it prints reinstall
372
372
  instructions. If another update is running, retry after it finishes.
373
373
 
374
+ Updates publish an initialized `update-install-*.lock` directory in the Ornn
375
+ config directory. Its unique owner file prevents competing processes from
376
+ removing a replacement lock during recovery or release. Directory locks from
377
+ processes that exit before installation are reclaimed on the next update
378
+ attempt; live or inaccessible PIDs and malformed metadata remain locked. Legacy regular-file locks require
379
+ manual cleanup after confirming that no updater is running: deleting them
380
+ automatically could remove a live lock created by an older CLI. PID reuse can
381
+ also require manual cleanup. An installation marker keeps the lock held if the
382
+ CLI is killed during installation, because its package installer may still be
383
+ running. Confirm that installer has stopped before removing the lock. Repeated
384
+ Ctrl+C or SIGTERM releases the lock after installer teardown. An interrupted
385
+ package installation may still require reinstalling the CLI if the package
386
+ manager has already removed the previous version.
387
+
374
388
  ## Deployment
375
389
 
376
390
  `deploy-cli-pipeline.yml` publishes `@ornncompute/cli` from `main`. Staging
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ornncompute/cli",
3
- "version": "0.2.12",
3
+ "version": "0.2.14",
4
4
  "description": "Command-line interface for Ornn compute access workflows.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/signals.mjs CHANGED
@@ -1,12 +1,15 @@
1
+ const FORWARDABLE_SIGNALS =
2
+ process.platform === "win32" ? ["SIGINT", "SIGTERM"] : ["SIGINT", "SIGTERM", "SIGHUP"];
3
+
1
4
  export function forwardSignals(signalSource, child, onSignal) {
2
- const listeners = ["SIGINT", "SIGTERM"].map((signal) => {
5
+ const listeners = FORWARDABLE_SIGNALS.map((signal) => {
3
6
  const listener = () => {
4
7
  onSignal(signal);
5
8
  try {
6
9
  child?.kill?.(signal);
7
10
  } catch {}
8
11
  };
9
- signalSource.once(signal, listener);
12
+ signalSource.on(signal, listener);
10
13
  return [signal, listener];
11
14
  });
12
15
  return () => {
package/src/startup.mjs CHANGED
@@ -73,7 +73,7 @@ function failedProbe(stderr) {
73
73
  function unavailableLock(stderr) {
74
74
  warning(
75
75
  stderr,
76
- "Automatic update unavailable; retry after any other ornn update finishes. If none is running, remove the stale update-install lock from your Ornn config directory or check that directory's permissions.",
76
+ "Automatic update unavailable; retry after any other ornn update finishes. If no updater or package installer is running, remove the stale update-install lock from your Ornn config directory or check that directory's permissions.",
77
77
  );
78
78
  return { kind: "exit", code: 1 };
79
79
  }
@@ -198,13 +198,13 @@ export async function prepareStartup(argv, options = {}) {
198
198
  spawnProcess: options.spawnProcess ?? spawn,
199
199
  };
200
200
 
201
- let release;
201
+ let lock;
202
202
  try {
203
203
  try {
204
- release = await acquireLock({ env, binPath: context.binPath ?? binPath });
204
+ lock = await acquireLock({ env, binPath: context.binPath ?? binPath });
205
205
  } catch {}
206
206
  if (interruptedSignal) return exitForSignal(interruptedSignal);
207
- if (typeof release !== "function") return unavailableLock(stderr);
207
+ if (!lock) return unavailableLock(stderr);
208
208
 
209
209
  let target;
210
210
  try {
@@ -232,7 +232,10 @@ export async function prepareStartup(argv, options = {}) {
232
232
 
233
233
  let installResult;
234
234
  try {
235
- installResult = await install({ context, version: target, ...subprocessOptions });
235
+ installResult = await lock.runInstall(() => {
236
+ if (interruptedSignal) return { signal: interruptedSignal };
237
+ return install({ context, version: target, ...subprocessOptions });
238
+ });
236
239
  } catch {}
237
240
  if (interruptedSignal || installResult?.signal) {
238
241
  return exitForSignal(interruptedSignal ?? installResult.signal);
@@ -265,7 +268,7 @@ export async function prepareStartup(argv, options = {}) {
265
268
  return { kind: "restart", binPath: replacementBin };
266
269
  } finally {
267
270
  removeSignals();
268
- if (typeof release === "function") await Promise.resolve(release()).catch(() => {});
271
+ if (lock) await Promise.resolve(lock.release()).catch(() => {});
269
272
  }
270
273
  }
271
274
 
@@ -0,0 +1,100 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import {
3
+ mkdir,
4
+ mkdtemp,
5
+ readFile,
6
+ readdir,
7
+ rename,
8
+ rm,
9
+ rmdir,
10
+ unlink,
11
+ writeFile,
12
+ } from "node:fs/promises";
13
+ import { dirname, join } from "node:path";
14
+
15
+ async function isStale(path) {
16
+ let pid;
17
+ try {
18
+ ({ pid } = JSON.parse(await readFile(path, "utf8")));
19
+ } catch {
20
+ return false;
21
+ }
22
+ if (!Number.isInteger(pid) || pid <= 0) return false;
23
+ try {
24
+ process.kill(pid, 0);
25
+ return false;
26
+ } catch (error) {
27
+ return error.code === "ESRCH";
28
+ }
29
+ }
30
+
31
+ async function removeOwner(lockPath, owner) {
32
+ await unlink(join(lockPath, owner)).catch(() => {});
33
+ // A replacement has a different owner file, so rmdir cannot remove it.
34
+ await rmdir(lockPath).catch(() => {});
35
+ }
36
+
37
+ async function reclaim(lockPath) {
38
+ let owners;
39
+ try {
40
+ owners = await readdir(lockPath);
41
+ } catch {
42
+ return;
43
+ }
44
+ if (owners.length === 0) {
45
+ await rmdir(lockPath).catch(() => {});
46
+ } else if (owners.length === 1 && (await isStale(join(lockPath, owners[0])))) {
47
+ await removeOwner(lockPath, owners[0]);
48
+ }
49
+ }
50
+
51
+ export async function acquireFileLock(lockPath) {
52
+ let pending;
53
+ const owner = randomUUID();
54
+ try {
55
+ await mkdir(dirname(lockPath), { mode: 0o700, recursive: true });
56
+ pending = await mkdtemp(`${lockPath}-`);
57
+ await writeFile(
58
+ join(pending, owner),
59
+ `${JSON.stringify({ pid: process.pid })}\n`,
60
+ {
61
+ mode: 0o600,
62
+ }
63
+ );
64
+ for (let attempt = 0; attempt < 2; attempt += 1) {
65
+ try {
66
+ // Publish metadata atomically; an occupied nonempty directory cannot be replaced.
67
+ await rename(pending, lockPath);
68
+ pending = null;
69
+ let released = false;
70
+ return {
71
+ async release() {
72
+ if (released) return;
73
+ released = true;
74
+ await removeOwner(lockPath, owner);
75
+ },
76
+ async runInstall(install) {
77
+ if (released) throw new Error("The update lock has already been released.");
78
+ const marker = join(lockPath, `${randomUUID()}.install`);
79
+ // A killed coordinator cannot prove its installer exited. Keep that lock held.
80
+ try {
81
+ await writeFile(marker, "", { mode: 0o600, flag: "wx" });
82
+ return await install();
83
+ } finally {
84
+ await unlink(marker).catch(() => {});
85
+ }
86
+ },
87
+ };
88
+ } catch (error) {
89
+ if (!["EEXIST", "ENOTEMPTY", "ENOTDIR", "EISDIR", "EPERM"].includes(error.code))
90
+ return null;
91
+ if (attempt === 0) await reclaim(lockPath);
92
+ }
93
+ }
94
+ } catch {
95
+ return null;
96
+ } finally {
97
+ if (pending) await rm(pending, { recursive: true, force: true }).catch(() => {});
98
+ }
99
+ return null;
100
+ }
package/src/update.mjs CHANGED
@@ -1,11 +1,12 @@
1
- import { createHash, randomUUID } from "node:crypto";
1
+ import { createHash } from "node:crypto";
2
2
  import { existsSync, realpathSync } from "node:fs";
3
- import { mkdir, open, readFile, rm } from "node:fs/promises";
3
+ import { readFile } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
5
  import { dirname, isAbsolute, join, posix, win32 } from "node:path";
6
6
  import { CliApiError } from "./api-client.mjs";
7
7
  import { getAuthConfigDir } from "./auth-store.mjs";
8
8
  import { forwardSignals } from "./signals.mjs";
9
+ import { acquireFileLock } from "./update-lock.mjs";
9
10
 
10
11
  export const UPDATE_CHECK_TIMEOUT_MS = 3000;
11
12
  const UPDATE_LOCK_PREFIX = "update-install";
@@ -241,41 +242,7 @@ function updateLockPath(env, binPath) {
241
242
 
242
243
  export async function acquireUpdateLock({ env = process.env, binPath }) {
243
244
  const lockPath = updateLockPath(env, binPath);
244
- if (!lockPath) {
245
- return null;
246
- }
247
-
248
- let handle;
249
- try {
250
- await mkdir(dirname(lockPath), { mode: 0o700, recursive: true });
251
- handle = await open(lockPath, "wx", 0o600);
252
- } catch {
253
- return null;
254
- }
255
-
256
- const token = randomUUID();
257
- try {
258
- await handle.writeFile(`${JSON.stringify({ pid: process.pid, token })}\n`, "utf8");
259
- await handle.close();
260
- } catch {
261
- await handle.close().catch(() => {});
262
- await rm(lockPath, { force: true }).catch(() => {});
263
- return null;
264
- }
265
-
266
- let released = false;
267
- return async () => {
268
- if (released) {
269
- return;
270
- }
271
- released = true;
272
- try {
273
- const current = JSON.parse(await readFile(lockPath, "utf8"));
274
- if (current?.token === token) {
275
- await rm(lockPath);
276
- }
277
- } catch {}
278
- };
245
+ return lockPath ? acquireFileLock(lockPath) : null;
279
246
  }
280
247
 
281
248
  function installInvocation(context, version) {
@@ -459,13 +426,13 @@ export async function updateCommand({
459
426
  );
460
427
  }
461
428
 
462
- const releaseLock = await acquireUpdateLock({ env, binPath: context.binPath });
463
- if (!releaseLock) {
429
+ const lock = await acquireUpdateLock({ env, binPath: context.binPath });
430
+ if (!lock) {
464
431
  const lockPath = updateLockPath(env, context.binPath);
465
432
  throw new CliApiError(
466
433
  `Could not acquire the update lock. If another ornn update is running, wait for it to finish and retry.${
467
434
  lockPath
468
- ? ` Otherwise remove the stale lock at ${lockPath} and retry.`
435
+ ? ` If no updater or package installer is running, remove the stale lock at ${lockPath} and retry.`
469
436
  : " Check the config directory permissions and retry."
470
437
  }`,
471
438
  );
@@ -491,7 +458,7 @@ export async function updateCommand({
491
458
  const version = latest ?? "latest";
492
459
  const [command, commandArgs] = installInvocation(context, version);
493
460
  stderr.write(`Running: ${command} ${commandArgs.join(" ")}\n`);
494
- const result = await installVersion({ context, version, spawnProcess, platform, env });
461
+ const result = await lock.runInstall(() => installVersion({ context, version, spawnProcess, platform, env }));
495
462
  if (result.exitCode !== 0) {
496
463
  throw new CliApiError(
497
464
  updateFailureMessage({
@@ -532,7 +499,7 @@ export async function updateCommand({
532
499
  }
533
500
  return 0;
534
501
  } finally {
535
- await releaseLock();
502
+ await lock.release();
536
503
  }
537
504
  }
538
505