@melaya/runner 1.1.33 → 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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@melaya/runner",
3
- "version": "1.1.33",
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,