@melaya/runner 1.1.32 → 1.1.34
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/dist/cli.js +13 -0
- package/dist/singleInstance.d.ts +31 -0
- package/dist/singleInstance.js +116 -0
- package/dist/uvBootstrap.js +41 -9
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -52,6 +52,19 @@ async function main() {
|
|
|
52
52
|
.parse(process.argv);
|
|
53
53
|
const opts = program.opts();
|
|
54
54
|
console.log(BANNER);
|
|
55
|
+
// Single-instance guard: refuse to start if another runner is already running
|
|
56
|
+
// on this machine. Two runners for one user fight over a single server-side
|
|
57
|
+
// session (connect/disconnect churn, turns landing on the wrong host), so we
|
|
58
|
+
// stop the second launch up front rather than let them compete. A stale lock
|
|
59
|
+
// from a crashed runner is reclaimed automatically.
|
|
60
|
+
const { acquireSingleInstanceLock } = await import("./singleInstance.js");
|
|
61
|
+
const lock = acquireSingleInstanceLock();
|
|
62
|
+
if (!lock.ok) {
|
|
63
|
+
console.log(chalk.red(` ✗ A Melaya runner is already running on this machine (pid ${lock.ownerPid}).`));
|
|
64
|
+
console.log(chalk.gray(" Only one runner can run per machine. Stop the other one first"));
|
|
65
|
+
console.log(chalk.gray(` (e.g. \`kill ${lock.ownerPid}\`, or close its terminal), then start again.`));
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
55
68
|
// Detect Python. Fast path: a supported host interpreter (3.10–3.12). If the
|
|
56
69
|
// host only has an unsupported/newer Python (Homebrew now ships 3.13/3.14 as
|
|
57
70
|
// `python3`) — or none at all — we PROVISION a managed CPython 3.12 via uv, so
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-instance guard for the runner.
|
|
3
|
+
*
|
|
4
|
+
* Two `@melaya/runner` processes for the same user fight over one server-side
|
|
5
|
+
* session: the server routes an assistant/pipeline turn to ONE socket, so a
|
|
6
|
+
* second runner produces connect/disconnect churn, split state, and turns that
|
|
7
|
+
* land on the wrong host. This lock makes a second launch on the same machine
|
|
8
|
+
* refuse up front ("can't start if another is already open") instead of quietly
|
|
9
|
+
* competing.
|
|
10
|
+
*
|
|
11
|
+
* Machine-scoped by design: it prevents the common footgun (double-launching on
|
|
12
|
+
* one box). It does NOT stop a runner on a *different* machine — that's a
|
|
13
|
+
* legitimate multi-device case, and the server's own per-user limit + the
|
|
14
|
+
* session reattach logic handle cross-machine arbitration.
|
|
15
|
+
*
|
|
16
|
+
* The lock is a small JSON file holding the owning pid. A stale lock (owner pid
|
|
17
|
+
* no longer alive — e.g. a hard kill that skipped cleanup) is reclaimed
|
|
18
|
+
* automatically, so a crash never permanently blocks the next start.
|
|
19
|
+
*/
|
|
20
|
+
export type LockResult = {
|
|
21
|
+
ok: true;
|
|
22
|
+
} | {
|
|
23
|
+
ok: false;
|
|
24
|
+
ownerPid: number;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Try to acquire the machine-wide runner lock. Returns {ok:true} and installs
|
|
28
|
+
* process-exit cleanup on success; {ok:false, ownerPid} if a live runner
|
|
29
|
+
* already holds it. A stale lock (dead owner) is reclaimed.
|
|
30
|
+
*/
|
|
31
|
+
export declare function acquireSingleInstanceLock(): LockResult;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-instance guard for the runner.
|
|
3
|
+
*
|
|
4
|
+
* Two `@melaya/runner` processes for the same user fight over one server-side
|
|
5
|
+
* session: the server routes an assistant/pipeline turn to ONE socket, so a
|
|
6
|
+
* second runner produces connect/disconnect churn, split state, and turns that
|
|
7
|
+
* land on the wrong host. This lock makes a second launch on the same machine
|
|
8
|
+
* refuse up front ("can't start if another is already open") instead of quietly
|
|
9
|
+
* competing.
|
|
10
|
+
*
|
|
11
|
+
* Machine-scoped by design: it prevents the common footgun (double-launching on
|
|
12
|
+
* one box). It does NOT stop a runner on a *different* machine — that's a
|
|
13
|
+
* legitimate multi-device case, and the server's own per-user limit + the
|
|
14
|
+
* session reattach logic handle cross-machine arbitration.
|
|
15
|
+
*
|
|
16
|
+
* The lock is a small JSON file holding the owning pid. A stale lock (owner pid
|
|
17
|
+
* no longer alive — e.g. a hard kill that skipped cleanup) is reclaimed
|
|
18
|
+
* automatically, so a crash never permanently blocks the next start.
|
|
19
|
+
*/
|
|
20
|
+
import { mkdirSync, readFileSync, unlinkSync, openSync, writeSync, closeSync } from "fs";
|
|
21
|
+
import { join } from "path";
|
|
22
|
+
import { homedir } from "os";
|
|
23
|
+
const CACHE_DIR = join(homedir(), ".melaya-runner");
|
|
24
|
+
const LOCK_PATH = join(CACHE_DIR, "runner.lock");
|
|
25
|
+
function pidAlive(pid) {
|
|
26
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
27
|
+
return false;
|
|
28
|
+
try {
|
|
29
|
+
// Signal 0 does not send a signal — it only checks the process exists.
|
|
30
|
+
// Throws ESRCH if gone, EPERM if alive but owned by another user (still
|
|
31
|
+
// "alive" for our purposes). Works on Windows too.
|
|
32
|
+
process.kill(pid, 0);
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
return e?.code === "EPERM";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Try to acquire the machine-wide runner lock. Returns {ok:true} and installs
|
|
41
|
+
* process-exit cleanup on success; {ok:false, ownerPid} if a live runner
|
|
42
|
+
* already holds it. A stale lock (dead owner) is reclaimed.
|
|
43
|
+
*/
|
|
44
|
+
export function acquireSingleInstanceLock() {
|
|
45
|
+
mkdirSync(CACHE_DIR, { recursive: true });
|
|
46
|
+
const writeOwn = () => {
|
|
47
|
+
// O_EXCL create: fails if the file already exists, which makes the
|
|
48
|
+
// check-and-claim atomic against another runner racing to start.
|
|
49
|
+
const fd = openSync(LOCK_PATH, "wx");
|
|
50
|
+
try {
|
|
51
|
+
writeSync(fd, JSON.stringify({ pid: process.pid, startedAt: Date.now() }));
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
closeSync(fd);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
try {
|
|
58
|
+
writeOwn();
|
|
59
|
+
}
|
|
60
|
+
catch (e) {
|
|
61
|
+
if (e?.code !== "EEXIST")
|
|
62
|
+
throw e;
|
|
63
|
+
// A lock exists — is its owner still alive?
|
|
64
|
+
let ownerPid = 0;
|
|
65
|
+
try {
|
|
66
|
+
ownerPid = Number(JSON.parse(readFileSync(LOCK_PATH, "utf-8"))?.pid) || 0;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
ownerPid = 0;
|
|
70
|
+
}
|
|
71
|
+
if (ownerPid && ownerPid !== process.pid && pidAlive(ownerPid)) {
|
|
72
|
+
return { ok: false, ownerPid };
|
|
73
|
+
}
|
|
74
|
+
// Stale (dead owner) or unreadable — reclaim it.
|
|
75
|
+
try {
|
|
76
|
+
unlinkSync(LOCK_PATH);
|
|
77
|
+
}
|
|
78
|
+
catch { /* someone else may have just cleaned it */ }
|
|
79
|
+
try {
|
|
80
|
+
writeOwn();
|
|
81
|
+
}
|
|
82
|
+
catch (e2) {
|
|
83
|
+
// Lost a reclaim race to another starting runner.
|
|
84
|
+
if (e2?.code === "EEXIST") {
|
|
85
|
+
let pid2 = 0;
|
|
86
|
+
try {
|
|
87
|
+
pid2 = Number(JSON.parse(readFileSync(LOCK_PATH, "utf-8"))?.pid) || 0;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
pid2 = 0;
|
|
91
|
+
}
|
|
92
|
+
return { ok: false, ownerPid: pid2 || -1 };
|
|
93
|
+
}
|
|
94
|
+
throw e2;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// Release the lock on every normal or signalled exit. Only unlink if WE still
|
|
98
|
+
// own it, so a reclaimed-after-crash successor never deletes the live owner's.
|
|
99
|
+
let released = false;
|
|
100
|
+
const release = () => {
|
|
101
|
+
if (released)
|
|
102
|
+
return;
|
|
103
|
+
released = true;
|
|
104
|
+
try {
|
|
105
|
+
const held = Number(JSON.parse(readFileSync(LOCK_PATH, "utf-8"))?.pid) || 0;
|
|
106
|
+
if (held === process.pid)
|
|
107
|
+
unlinkSync(LOCK_PATH);
|
|
108
|
+
}
|
|
109
|
+
catch { /* already gone */ }
|
|
110
|
+
};
|
|
111
|
+
process.on("exit", release);
|
|
112
|
+
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
113
|
+
process.on(sig, () => { release(); process.exit(0); });
|
|
114
|
+
}
|
|
115
|
+
return { ok: true };
|
|
116
|
+
}
|
package/dist/uvBootstrap.js
CHANGED
|
@@ -57,9 +57,10 @@ function findOnPath(bin) {
|
|
|
57
57
|
}
|
|
58
58
|
// Inactivity-bounded child runner (mirrors pythonEnv.runProc): a managed-CPython
|
|
59
59
|
// download legitimately takes a while but streams progress, so we bound silence.
|
|
60
|
-
function run(cmd, args, onLine,
|
|
60
|
+
function run(cmd, args, onLine, opts = {}) {
|
|
61
|
+
const inactivityMs = opts.inactivityMs ?? 5 * 60_000;
|
|
61
62
|
return new Promise((resolve) => {
|
|
62
|
-
const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"], env: process.env });
|
|
63
|
+
const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, ...opts.env }, cwd: opts.cwd });
|
|
63
64
|
let done = false;
|
|
64
65
|
let timer;
|
|
65
66
|
const finish = (c) => { if (done)
|
|
@@ -85,15 +86,38 @@ function run(cmd, args, onLine, inactivityMs = 5 * 60_000) {
|
|
|
85
86
|
bump();
|
|
86
87
|
});
|
|
87
88
|
}
|
|
88
|
-
function capture(cmd, args) {
|
|
89
|
+
function capture(cmd, args, env = {}) {
|
|
89
90
|
return new Promise((resolve) => {
|
|
90
91
|
let out = "";
|
|
91
|
-
const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"], env: process.env });
|
|
92
|
+
const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, ...env } });
|
|
92
93
|
child.stdout?.on("data", (b) => { out += b.toString(); });
|
|
93
94
|
child.on("exit", () => resolve(out.trim()));
|
|
94
95
|
child.on("error", () => resolve(""));
|
|
95
96
|
});
|
|
96
97
|
}
|
|
98
|
+
// Extract the downloaded uv archive. uv ships .tar.gz for macOS/Linux and .zip
|
|
99
|
+
// for Windows, so extraction must branch by type — a single `tar` call can't
|
|
100
|
+
// cover both portably (a shell's `tar` may be MSYS GNU tar, which can't read
|
|
101
|
+
// zip). destDir already contains the archive; we extract in place.
|
|
102
|
+
async function extractArchive(archivePath, asset, destDir, onLine) {
|
|
103
|
+
if (asset.endsWith(".zip")) {
|
|
104
|
+
// Windows only. PowerShell's Expand-Archive is always present and reliably
|
|
105
|
+
// reads zips, unlike whatever `tar` happens to be on PATH.
|
|
106
|
+
const ps = `Expand-Archive -LiteralPath '${archivePath.replace(/'/g, "''")}' -DestinationPath '${destDir.replace(/'/g, "''")}' -Force`;
|
|
107
|
+
let code = await run("powershell", ["-NoProfile", "-NonInteractive", "-Command", ps], onLine);
|
|
108
|
+
if (code === 0)
|
|
109
|
+
return true;
|
|
110
|
+
// Fallback: Windows' bundled bsdtar (System32\tar.exe) also handles zip.
|
|
111
|
+
const sys32 = join(process.env.SystemRoot || "C:\\Windows", "System32", "tar.exe");
|
|
112
|
+
code = await run(existsSync(sys32) ? sys32 : "tar", ["-xf", asset, "-C", "."], onLine, { cwd: destDir });
|
|
113
|
+
return code === 0;
|
|
114
|
+
}
|
|
115
|
+
// macOS/Linux: .tar.gz — bsdtar (macOS) and GNU tar (Linux) both read gz.
|
|
116
|
+
// cwd + bare relative name keeps args colon-free (harmless on unix, required
|
|
117
|
+
// on Windows for the fallback path above).
|
|
118
|
+
const code = await run("tar", ["-xf", asset, "-C", "."], onLine, { cwd: destDir });
|
|
119
|
+
return code === 0;
|
|
120
|
+
}
|
|
97
121
|
function findBinaryRecursive(dir, name) {
|
|
98
122
|
let entries;
|
|
99
123
|
try {
|
|
@@ -154,9 +178,9 @@ async function downloadUv(onLine) {
|
|
|
154
178
|
return null;
|
|
155
179
|
}
|
|
156
180
|
onLine("extracting uv");
|
|
157
|
-
const
|
|
158
|
-
if (
|
|
159
|
-
onLine("
|
|
181
|
+
const extracted = await extractArchive(archivePath, asset, tmp, onLine);
|
|
182
|
+
if (!extracted) {
|
|
183
|
+
onLine("extraction of the uv archive failed");
|
|
160
184
|
return null;
|
|
161
185
|
}
|
|
162
186
|
const found = findBinaryRecursive(tmp, uvBinName());
|
|
@@ -200,12 +224,20 @@ export async function provisionPython(version, onLine) {
|
|
|
200
224
|
if (!uv)
|
|
201
225
|
return null;
|
|
202
226
|
onLine(`installing a managed Python ${version} (one-time; ~30-60s)`);
|
|
203
|
-
|
|
227
|
+
// UV_SYSTEM_CERTS makes uv verify against the OS trust store instead of its
|
|
228
|
+
// bundled webpki roots — required behind TLS-intercepting proxies/antivirus
|
|
229
|
+
// (else "invalid peer certificate: UnknownIssuer") and on networks whose
|
|
230
|
+
// issuer isn't in webpki. Set as an env var (not the CLI flag) so it's a
|
|
231
|
+
// no-op on uv versions that don't know it rather than an unknown-flag error;
|
|
232
|
+
// the flag was also renamed from --native-tls, and env vars dodge that churn.
|
|
233
|
+
// UV_HTTP_TIMEOUT lifts the per-request timeout for the ~20MB CPython download.
|
|
234
|
+
const uvEnv = { UV_SYSTEM_CERTS: "1", UV_HTTP_TIMEOUT: "300" };
|
|
235
|
+
const code = await run(uv, ["python", "install", version], onLine, { env: uvEnv });
|
|
204
236
|
if (code !== 0) {
|
|
205
237
|
onLine(`uv python install ${version} failed`);
|
|
206
238
|
return null;
|
|
207
239
|
}
|
|
208
|
-
const path = await capture(uv, ["python", "find", version]);
|
|
240
|
+
const path = await capture(uv, ["python", "find", version], uvEnv);
|
|
209
241
|
if (path && existsSync(path))
|
|
210
242
|
return path;
|
|
211
243
|
onLine(`uv could not resolve a Python ${version} interpreter path after install`);
|