@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 +14 -0
- package/package.json +1 -1
- package/src/signals.mjs +5 -2
- package/src/startup.mjs +9 -6
- package/src/update-lock.mjs +100 -0
- package/src/update.mjs +9 -42
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
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 =
|
|
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.
|
|
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
|
|
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
|
|
201
|
+
let lock;
|
|
202
202
|
try {
|
|
203
203
|
try {
|
|
204
|
-
|
|
204
|
+
lock = await acquireLock({ env, binPath: context.binPath ?? binPath });
|
|
205
205
|
} catch {}
|
|
206
206
|
if (interruptedSignal) return exitForSignal(interruptedSignal);
|
|
207
|
-
if (
|
|
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
|
|
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 (
|
|
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
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
2
|
import { existsSync, realpathSync } from "node:fs";
|
|
3
|
-
import {
|
|
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
|
-
|
|
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
|
|
463
|
-
if (!
|
|
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
|
-
? `
|
|
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
|
|
502
|
+
await lock.release();
|
|
536
503
|
}
|
|
537
504
|
}
|
|
538
505
|
|