@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 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
+ }
@@ -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, inactivityMs = 5 * 60_000) {
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 code = await run("tar", ["-xf", archivePath, "-C", tmp], onLine);
158
- if (code !== 0) {
159
- onLine("tar extraction of uv failed (is `tar` available?)");
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
- const code = await run(uv, ["python", "install", version], onLine);
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`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@melaya/runner",
3
- "version": "1.1.32",
3
+ "version": "1.1.34",
4
4
  "description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,