mobile-automator 0.26.0 → 0.28.0

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.
@@ -6,6 +6,26 @@ const { StdioClientTransport } = require('@modelcontextprotocol/sdk/client/stdio
6
6
  const { injectDeviceArg } = require('./tool-args');
7
7
  const { resolveSingleDevice } = require('./device-resolver');
8
8
  const { normalizeDevices } = require('./device-model');
9
+ const { ErrorCode } = require('@modelcontextprotocol/sdk/types.js');
10
+
11
+ // Bound on mobile-mcp's MCP initialize handshake (#156). Measured at ~200ms on
12
+ // mobile-mcp 1.0.5 — initialize touches no device — so 10s is ~50x headroom.
13
+ // The spawner kills an unready daemon when its readiness window closes, and a
14
+ // failed connect is reported only once the engine is reaped, so
15
+ // CONNECT_TIMEOUT_MS + ENGINE_REAP_MAX_MS, plus headroom for the daemon's own
16
+ // boot, MUST stay below session-spawn's DEFAULT_READY_TIMEOUT_MS — or the
17
+ // daemon is killed instead of failing with its own daemon.connect_failure.
18
+ // Pinned by tests/unit/device/timeout-invariant.test.js.
19
+ // (The SDK's own default is 60s, which is longer than the window watching it.)
20
+ const CONNECT_TIMEOUT_MS = 10000;
21
+
22
+ // A failed connect terminates the engine at once rather than waiting out the
23
+ // SDK's fire-and-forget close (stdin end → 2s → SIGTERM → 2s → SIGKILL): an
24
+ // engine that never initialized has nothing to flush, and every second spent
25
+ // here is spent inside the spawner's readiness window. SIGTERM, SIGKILL after
26
+ // ENGINE_KILL_GRACE_MS, then up to ENGINE_KILL_GRACE_MS more for the death.
27
+ const ENGINE_KILL_GRACE_MS = 1000;
28
+ const ENGINE_REAP_MAX_MS = 2 * ENGINE_KILL_GRACE_MS;
9
29
 
10
30
  // Resolve the mobile-mcp server entry point from the bundled, pinned
11
31
  // dependency rather than fetching `@latest` at runtime. We read the package's
@@ -26,25 +46,69 @@ function resolveServerEntry() {
26
46
  return path.join(pkgDir, binRel);
27
47
  }
28
48
 
49
+ // The SDK transport, remembering the engine pid the moment the child is spawned
50
+ // (#156). It has to be remembered: on a failed connect the SDK runs
51
+ // `void this.close()`, and StdioClientTransport.close() clears its pid
52
+ // synchronously — so by the time createCall's catch runs, `pid` is already null
53
+ // while the child may still be alive. `spawnedPid` is never cleared.
54
+ // Client.connect() awaits transport.start(), which is what makes start() the
55
+ // place to read it; tests/integration/engine-lifecycle.test.js pins that against
56
+ // the real SDK (a failed connect still reaps the engine).
57
+ function pidTracking(Transport) {
58
+ return class PidTrackingTransport extends Transport {
59
+ async start() {
60
+ await super.start();
61
+ this.spawnedPid = this.pid;
62
+ }
63
+ };
64
+ }
65
+
29
66
  // Integration glue: spawns mobile-mcp over stdio and returns a `call`
30
67
  // function shaped exactly like the one DeviceBridge expects.
31
- // NOT unit-tested (would require spawning mobile-mcp); see the smoke test.
32
- async function createCall({ device } = {}) {
33
- const serverEntry = resolveServerEntry();
34
- const transport = new StdioClientTransport({
35
- command: process.execPath,
36
- args: [serverEntry],
68
+ //
69
+ // `sdk`, `server` and `reaper` are test seams: production passes none of them.
70
+ // tests/unit/device/mobile-mcp-client-lifecycle.test.js drives this against a
71
+ // fake SDK; tests/integration/engine-lifecycle.test.js against the real SDK and
72
+ // a fixture MCP server.
73
+ async function createCall({
74
+ device,
75
+ connectTimeoutMs = CONNECT_TIMEOUT_MS,
76
+ onEngineExit = null,
77
+ sdk = { Client, StdioClientTransport },
78
+ server = null,
79
+ reaper = require('./process-reaper'),
80
+ } = {}) {
81
+ // Resolved lazily, here, so the module still loads without mobile-mcp installed.
82
+ const { command, args } = server || { command: process.execPath, args: [resolveServerEntry()] };
83
+ const Transport = pidTracking(sdk.StdioClientTransport);
84
+ const transport = new Transport({
85
+ command,
86
+ args,
37
87
  // mobile-mcp reads the target device from the environment / its own
38
88
  // selection; we pass it through when provided so callers can pin a device.
39
89
  env: device ? { ...process.env, MOBILE_MCP_DEVICE: device } : process.env,
40
90
  });
41
91
 
42
- const client = new Client(
43
- { name: 'mauto', version: '0.1.0' },
44
- { capabilities: {} }
45
- );
92
+ const client = new sdk.Client({ name: 'mauto', version: '0.1.0' }, { capabilities: {} });
46
93
 
47
- await client.connect(transport);
94
+ // Engine liveness (#156). The SDK fires onclose on EVERY child close — a real
95
+ // engine death and our own close() alike — so `closing` separates the two.
96
+ // A failed connect also sets it: that failure is reported by the throw.
97
+ let closing = false;
98
+ let reported = false;
99
+ client.onclose = () => {
100
+ if (closing || reported) return;
101
+ reported = true;
102
+ if (typeof onEngineExit === 'function') onEngineExit();
103
+ };
104
+
105
+ try {
106
+ await client.connect(transport, { timeout: connectTimeoutMs });
107
+ } catch (err) {
108
+ closing = true;
109
+ await reapEngine(transport.spawnedPid, reaper);
110
+ throw connectError(err, connectTimeoutMs);
111
+ }
48
112
 
49
113
  async function rawCall(toolName, args = {}) {
50
114
  const res = await client.callTool({ name: toolName, arguments: args });
@@ -53,6 +117,7 @@ async function createCall({ device } = {}) {
53
117
  const { call } = makeCall({ rawCall, device });
54
118
 
55
119
  async function close() {
120
+ closing = true;
56
121
  try {
57
122
  await client.close();
58
123
  } catch (_e) {
@@ -63,6 +128,27 @@ async function createCall({ device } = {}) {
63
128
  return { call, close };
64
129
  }
65
130
 
131
+ // The SDK's connect-failure cleanup is fire-and-forget, so the engine is
132
+ // terminated here, at once, and the error leaves createCall only once it is
133
+ // gone — nothing may outlive the failure it reports. The budget-0 check first:
134
+ // an engine that already exited (the ConnectionClosed path) is not signalled.
135
+ // group:false: the engine is not a process-group leader.
136
+ async function reapEngine(pid, reaper) {
137
+ if (!pid) return;
138
+ if (await reaper.waitForDeath(pid, { budgetMs: 0 })) return;
139
+ await reaper.terminate(pid, { group: false, graceMs: ENGINE_KILL_GRACE_MS });
140
+ }
141
+
142
+ // A timed-out initialize becomes an error that names the bound; anything else
143
+ // (spawn ENOENT, a protocol mismatch) passes through untouched.
144
+ function connectError(err, timeoutMs) {
145
+ if (!(err && err.code === ErrorCode.RequestTimeout)) return err;
146
+ const e = new Error(`mobile-mcp did not finish initializing within ${timeoutMs}ms`);
147
+ e.code = 'ENGINE_CONNECT_TIMEOUT';
148
+ e.cause = err;
149
+ return e;
150
+ }
151
+
66
152
  // Pure orchestration over a low-level rawCall(toolName, args) => Promise<parsed>.
67
153
  // Threads a concrete device id into every tool call except the discovery verb,
68
154
  // lazily auto-discovering a single device when none was pinned (cached after
@@ -160,4 +246,13 @@ function parseToolResult(res) {
160
246
  return res;
161
247
  }
162
248
 
163
- module.exports = { createCall, makeCall, parseToolResult };
249
+ // resolveServerEntry is exported so tests/integration/mobile-mcp-contract.test.js
250
+ // spawns the pinned engine by the exact path production does, not a copy of it.
251
+ module.exports = {
252
+ createCall,
253
+ makeCall,
254
+ parseToolResult,
255
+ resolveServerEntry,
256
+ CONNECT_TIMEOUT_MS,
257
+ ENGINE_REAP_MAX_MS,
258
+ };
@@ -0,0 +1,124 @@
1
+ 'use strict';
2
+
3
+ // Process identity for the device session lifecycle (#156): is a pid alive,
4
+ // is it a zombie awaiting a reaper, is it OUR daemon, and which pid does a
5
+ // pid/lock file name. A leaf module on purpose — session-daemon.js (cleanStale),
6
+ // process-reaper.js (its waits) and connection.js (`session end`) all ask these
7
+ // questions, and none of them should have to load the daemon to do so.
8
+ //
9
+ // Every effect that can fail on a slim host (`ps`, /proc) is injectable, so the
10
+ // classification is unit-testable against a fake process table.
11
+
12
+ const childProcess = require('child_process');
13
+ const fs = require('fs');
14
+
15
+ const paths = require('./session-paths');
16
+
17
+ // True when a pid is a live process we own. ESRCH => dead; EPERM => alive.
18
+ // cleanStale routes through pidIdentity, which also checks the process's
19
+ // identity (not just liveness).
20
+ function pidAlive(pid) {
21
+ if (!pid || !Number.isInteger(pid)) return false;
22
+ try {
23
+ process.kill(pid, 0);
24
+ return true;
25
+ } catch (err) {
26
+ return err.code === 'EPERM';
27
+ }
28
+ }
29
+
30
+ // Has `pid` exited and is only awaiting a parent to reap it? kill(pid, 0)
31
+ // succeeds on a zombie, so pidAlive alone calls an exited process alive (#156).
32
+ // That is the normal end state of a daemon wherever PID 1 does not reap
33
+ // adopted orphans — Docker without --init, CI job containers whose PID 1 is
34
+ // `tail -f /dev/null`, a Node agent host running as PID 1 — because the
35
+ // detached daemon is reparented to it when the verb that spawned it exits.
36
+ // Any failure to tell answers "not a zombie", so liveness decides as before.
37
+ // Used by process-reaper.js's waits; lives here, beside pidAlive and
38
+ // pidIdentity, because this is the module that runs `ps`.
39
+ function isZombie(
40
+ pid,
41
+ { platform = process.platform, readFile = fs.readFileSync, execFile = childProcess.execFileSync } = {}
42
+ ) {
43
+ try {
44
+ if (platform === 'linux') {
45
+ // "pid (comm) S ..." — comm may itself contain spaces or ')', so the state
46
+ // is the field after the LAST ')'. Reading /proc needs no `ps`, which
47
+ // slim container images do not ship.
48
+ const stat = String(readFile(`/proc/${pid}/stat`, 'utf8'));
49
+ return /^\s*[ZX]/.test(stat.slice(stat.lastIndexOf(')') + 1));
50
+ }
51
+ const out = execFile('ps', ['-o', 'stat=', '-p', String(pid)], {
52
+ encoding: 'utf8',
53
+ stdio: ['ignore', 'pipe', 'ignore'],
54
+ });
55
+ return /^\s*Z/.test(String(out));
56
+ } catch (_) {
57
+ return false;
58
+ }
59
+ }
60
+
61
+ // Classify a pid by liveness AND identity. Liveness alone is not enough: a
62
+ // recycled pid (an unrelated process that inherited the dead daemon's pid) is
63
+ // "alive" but is NOT our daemon, so a stale lock/pidfile pointing at it must be
64
+ // cleaned rather than respected. Returns one of:
65
+ // 'dead' - no such process (ESRCH or any non-EPERM kill error)
66
+ // 'ours' - alive AND its command line contains `mauto-session-daemon`
67
+ // (and, when `projectRoot` is given, names THAT workspace)
68
+ // 'foreign' - only with `projectRoot`: alive and a mauto daemon, but its
69
+ // command line does not name that workspace (#156) — another
70
+ // workspace's daemon, one started before the workspace marker
71
+ // existed, or an editor open on the bin. Never ours to signal.
72
+ // 'other' - alive AND its command line does NOT (recycled pid)
73
+ // 'unknown' - alive but `ps` itself failed (e.g. no `ps` on the host) —
74
+ // treated as 'ours' by cleanStale: never clean when unsure.
75
+ // `execFile` is injectable for tests; a failure there must never crash the
76
+ // daemon, hence the 'unknown' fallback.
77
+ //
78
+ // cleanStale passes no projectRoot and keeps the binary-level answer: there a
79
+ // wrong 'ours' only means stale files are left alone. `session end` passes one,
80
+ // because there a wrong 'ours' sends a process-group kill (src/device/connection.js).
81
+ function pidIdentity(pid, { execFile = childProcess.execFileSync, projectRoot = null } = {}) {
82
+ if (!pid || !Number.isInteger(pid)) return 'dead';
83
+ // Our own pid is the in-process daemon (unit tests run startDaemon in the
84
+ // test process, so the lock/pidfile carry the test runner's pid). Never clean
85
+ // files that point at ourselves. In production cleanStale runs before the
86
+ // daemon writes its own pid/lock, so this branch only fires in-process.
87
+ if (pid === process.pid) return 'ours';
88
+ let alive;
89
+ try {
90
+ process.kill(pid, 0);
91
+ alive = true;
92
+ } catch (err) {
93
+ if (err.code === 'EPERM') alive = true;
94
+ else return 'dead';
95
+ }
96
+ if (!alive) return 'dead';
97
+ try {
98
+ // -ww: never truncate the line — the workspace marker is at its END. stderr
99
+ // ignored: a failing `ps` is already the 'unknown' answer, not verb output.
100
+ const out = String(
101
+ execFile('ps', ['-ww', '-p', String(pid), '-o', 'command='], {
102
+ encoding: 'utf8',
103
+ stdio: ['ignore', 'pipe', 'ignore'],
104
+ }) || ''
105
+ );
106
+ if (!/mauto-session-daemon/.test(out)) return 'other';
107
+ if (projectRoot == null) return 'ours';
108
+ return paths.commandNamesWorkspace(out, projectRoot) ? 'ours' : 'foreign';
109
+ } catch (_) {
110
+ return 'unknown';
111
+ }
112
+ }
113
+
114
+ // Read a pid out of a one-line pid/lock file, or null when absent/garbage.
115
+ function readPidFile(file) {
116
+ try {
117
+ const pid = parseInt(fs.readFileSync(file, 'utf8').trim(), 10);
118
+ return Number.isInteger(pid) ? pid : null;
119
+ } catch (_) {
120
+ return null;
121
+ }
122
+ }
123
+
124
+ module.exports = { pidAlive, isZombie, pidIdentity, readPidFile };
@@ -0,0 +1,123 @@
1
+ 'use strict';
2
+
3
+ // Process termination for the device session lifecycle (#156). Two callers:
4
+ // session-spawn.js kills a daemon that never became ready, and `session end`
5
+ // (connection.js) escalates a daemon stuck in teardown.
6
+ //
7
+ // Mechanism only. This module never decides WHOSE pid it is signalling — the
8
+ // spawner holds its own ChildProcess, and `session end` runs pidIdentity first.
9
+ // Every effect (kill, liveness, clock) is injected so the escalation policy is
10
+ // tested once, here, against a fake process table.
11
+
12
+ const { pidAlive, isZombie } = require('./process-identity');
13
+
14
+ // Alive, and not merely awaiting a reaper (#156): kill(pid, 0) succeeds on a
15
+ // zombie, so plain liveness waits out a whole budget for a daemon that has
16
+ // already exited wherever PID 1 does not reap (see isZombie). The default
17
+ // liveness for every wait below; the zombie check runs only for a pid that
18
+ // kill(pid, 0) calls alive.
19
+ function processAlive(pid) {
20
+ return pidAlive(pid) && !isZombie(pid);
21
+ }
22
+
23
+ // Deliberately NOT unref'd: `session end` is a one-shot verb process with
24
+ // nothing else on its event loop while it waits, so an unref'd timer would let
25
+ // the process exit mid-wait and report nothing.
26
+ function defaultSleep(ms) {
27
+ return new Promise((resolve) => setTimeout(resolve, ms));
28
+ }
29
+
30
+ // Resolves true as soon as `done()` holds, false once `budgetMs` has elapsed.
31
+ // budgetMs 0 is a single check.
32
+ async function waitUntil(done, { budgetMs, pollMs, sleep, now }) {
33
+ const deadline = now() + budgetMs;
34
+ for (;;) {
35
+ if (done()) return true;
36
+ const left = deadline - now();
37
+ if (left <= 0) return false;
38
+ await sleep(Math.min(pollMs, left));
39
+ }
40
+ }
41
+
42
+ // Resolves true as soon as `pid` is dead, false once `budgetMs` has elapsed.
43
+ // budgetMs 0 is a single check.
44
+ function waitForDeath(
45
+ pid,
46
+ { budgetMs, pollMs = 100, isAlive = processAlive, sleep = defaultSleep, now = Date.now } = {}
47
+ ) {
48
+ return waitUntil(() => !isAlive(pid), { budgetMs, pollMs, sleep, now });
49
+ }
50
+
51
+ // Does process group `pgid` still have a member? kill(-pgid, 0) succeeds while
52
+ // any member exists; EPERM means members we may not signal, which still exist.
53
+ function groupExists(kill, pgid) {
54
+ try {
55
+ kill(-pgid, 0);
56
+ return true;
57
+ } catch (err) {
58
+ return !!(err && err.code === 'EPERM');
59
+ }
60
+ }
61
+
62
+ // One signal. 'denied' (EPERM) means a process we may not signal, which is not
63
+ // ours to report as killed. ESRCH is NOT trusted as death: for a group kill it
64
+ // can mean "no such group" while the pid itself is alive, so the caller always
65
+ // confirms with isAlive.
66
+ function send(kill, target, sig) {
67
+ try {
68
+ kill(target, sig);
69
+ return 'sent';
70
+ } catch (err) {
71
+ if (err && err.code === 'EPERM') return 'denied';
72
+ if (err && err.code === 'ESRCH') return 'gone';
73
+ throw err;
74
+ }
75
+ }
76
+
77
+ // SIGTERM, wait up to graceMs, SIGKILL, wait up to graceMs, report.
78
+ //
79
+ // group:true signals -pid, the process GROUP. That only works for a group
80
+ // leader: the daemon is one because session-spawn.js spawns it with
81
+ // `detached: true`, which is also what makes -pid reach the mobile-mcp engine
82
+ // and its mobilecli (spawned non-detached, so in the daemon's group). Remove
83
+ // `detached` there and -pid becomes ESRCH — reported here as 'survived', never
84
+ // as a false 'dead'. group:false is for the engine child, which leads no group.
85
+ //
86
+ // For a group, SIGTERM is enough only once the leader AND every member are
87
+ // gone (#156). The leader alone is not the group: a daemon before listen() has
88
+ // no SIGTERM handler and dies at once, while mobile-mcp installs one that a
89
+ // blocked event loop cannot run — so the engine outlives the leader and must
90
+ // still get the SIGKILL. SIGKILL cannot be caught, so after it the leader's
91
+ // death is the answer (a member left behind is a zombie awaiting its reaper).
92
+ async function terminate(
93
+ pid,
94
+ {
95
+ group = true,
96
+ graceMs = 2000,
97
+ pollMs = 50,
98
+ kill = process.kill.bind(process),
99
+ isAlive = processAlive,
100
+ sleep = defaultSleep,
101
+ now = Date.now,
102
+ } = {}
103
+ ) {
104
+ // kill(0) / kill(-1) / a negative pid would signal our own group or every
105
+ // process we own. Refuse rather than guess. A GROUP kill of pid 1 is
106
+ // kill(-1) — the same broadcast — so it is refused too.
107
+ if (!Number.isInteger(pid) || pid <= 0 || (group && pid === 1)) {
108
+ throw new TypeError(`terminate requires a positive integer pid${group ? ' above 1' : ''}, got ${pid}`);
109
+ }
110
+ const target = group ? -pid : pid;
111
+ const wait = { budgetMs: graceMs, pollMs, sleep, now };
112
+ const gone = group
113
+ ? () => !isAlive(pid) && !groupExists(kill, pid)
114
+ : () => !isAlive(pid);
115
+
116
+ if (send(kill, target, 'SIGTERM') === 'denied') return isAlive(pid) ? 'survived' : 'dead';
117
+ if (await waitUntil(gone, wait)) return 'dead';
118
+ if (send(kill, target, 'SIGKILL') === 'denied') return isAlive(pid) ? 'survived' : 'dead';
119
+ if (await waitUntil(() => !isAlive(pid), wait)) return 'dead';
120
+ return 'survived';
121
+ }
122
+
123
+ module.exports = { waitForDeath, terminate, processAlive };
@@ -150,21 +150,27 @@ async function requestShutdown(projectRoot) {
150
150
  // Rich status probe: connect + ping and report whether a daemon is running,
151
151
  // how many device calls are currently in flight (the double-execution window
152
152
  // is visible here), and which device it is pinned to. `null`s when not running.
153
+ //
154
+ // A daemon whose engine has died is NOT running (#156): it is draining toward
155
+ // its own exit and can never serve a call. Only an explicit engine_alive:false
156
+ // counts — a pre-0.28 daemon omits the field and is treated as alive.
153
157
  async function getSessionStatus(projectRoot) {
158
+ const down = { running: false, reason: null, in_flight: null, device: null };
154
159
  const socket = await connectSocket(paths.socketPath(projectRoot));
155
- if (!socket) return { running: false, in_flight: null, device: null };
160
+ if (!socket) return down;
156
161
  const client = wrapSocket(socket);
157
162
  try {
158
163
  const res = await client.request({ type: 'ping' }, { timeoutMs: 2000 });
159
- if (!(res && res.ok)) return { running: false, in_flight: null, device: null };
164
+ if (!(res && res.ok)) return down;
160
165
  const result = res.result || {};
161
- return {
162
- running: true,
166
+ const seen = {
163
167
  in_flight: typeof result.in_flight === 'number' ? result.in_flight : null,
164
168
  device: result.device != null ? result.device : null,
165
169
  };
170
+ if (result.engine_alive === false) return { running: false, reason: 'engine_exited', ...seen };
171
+ return { running: true, reason: null, ...seen };
166
172
  } catch (_) {
167
- return { running: false, in_flight: null, device: null };
173
+ return down;
168
174
  } finally {
169
175
  await client.close();
170
176
  }
@@ -18,7 +18,6 @@
18
18
  // Everything that touches a real device is injected, so the full lifecycle is
19
19
  // unit-testable with a fake createCall and a mkdtemp project root.
20
20
 
21
- const childProcess = require('child_process');
22
21
  const fs = require('fs');
23
22
  const net = require('net');
24
23
 
@@ -28,6 +27,7 @@ const { newSessionId } = require('./session-handle');
28
27
  const { makeDeviceCall } = require('./device-call');
29
28
  const { safeObserve: guardObserve } = require('../observe/recorder');
30
29
  const { FLUSH_INTERVAL_MS } = require('../observe/flush');
30
+ const { pidIdentity, readPidFile } = require('./process-identity');
31
31
 
32
32
  const DEFAULT_IDLE_MS = 5 * 60 * 1000;
33
33
 
@@ -62,64 +62,6 @@ function safeUnlink(p) {
62
62
  }
63
63
  }
64
64
 
65
- // True when a pid is a live process we own. ESRCH => dead; EPERM => alive.
66
- // Kept exported for back-compat; cleanStale routes through pidIdentity, which
67
- // also checks the process's identity (not just liveness).
68
- function pidAlive(pid) {
69
- if (!pid || !Number.isInteger(pid)) return false;
70
- try {
71
- process.kill(pid, 0);
72
- return true;
73
- } catch (err) {
74
- return err.code === 'EPERM';
75
- }
76
- }
77
-
78
- // Classify a pid by liveness AND identity. Liveness alone is not enough: a
79
- // recycled pid (an unrelated process that inherited the dead daemon's pid) is
80
- // "alive" but is NOT our daemon, so a stale lock/pidfile pointing at it must be
81
- // cleaned rather than respected. Returns one of:
82
- // 'dead' - no such process (ESRCH or any non-EPERM kill error)
83
- // 'ours' - alive AND its command line contains `mauto-session-daemon`
84
- // 'other' - alive AND its command line does NOT (recycled pid)
85
- // 'unknown' - alive but `ps` itself failed (e.g. no `ps` on the host) —
86
- // treated as 'ours' by cleanStale: never clean when unsure.
87
- // `execFile` is injectable for tests; a failure there must never crash the
88
- // daemon, hence the 'unknown' fallback.
89
- function pidIdentity(pid, { execFile = childProcess.execFileSync } = {}) {
90
- if (!pid || !Number.isInteger(pid)) return 'dead';
91
- // Our own pid is the in-process daemon (unit tests run startDaemon in the
92
- // test process, so the lock/pidfile carry the test runner's pid). Never clean
93
- // files that point at ourselves. In production cleanStale runs before the
94
- // daemon writes its own pid/lock, so this branch only fires in-process.
95
- if (pid === process.pid) return 'ours';
96
- let alive;
97
- try {
98
- process.kill(pid, 0);
99
- alive = true;
100
- } catch (err) {
101
- if (err.code === 'EPERM') alive = true;
102
- else return 'dead';
103
- }
104
- if (!alive) return 'dead';
105
- try {
106
- const out = execFile('ps', ['-p', String(pid), '-o', 'command='], { encoding: 'utf8' });
107
- return /mauto-session-daemon/.test(String(out || '')) ? 'ours' : 'other';
108
- } catch (_) {
109
- return 'unknown';
110
- }
111
- }
112
-
113
- // Read a pid out of a one-line pid/lock file, or null when absent/garbage.
114
- function readPidFile(file) {
115
- try {
116
- const pid = parseInt(fs.readFileSync(file, 'utf8').trim(), 10);
117
- return Number.isInteger(pid) ? pid : null;
118
- } catch (_) {
119
- return null;
120
- }
121
- }
122
-
123
65
  // Remove stale socket/pidfile/handle/lock left by a crashed daemon so a fresh
124
66
  // daemon can bind. Identity is decided from the LOCK file first (acquired before
125
67
  // the connection is built, so it covers a daemon still mid-startup) then the
@@ -161,7 +103,9 @@ async function startDaemon({
161
103
  createCall = defaultCreateCall,
162
104
  onUndeliverable = null,
163
105
  scheduleTimeout = defaultScheduleTimeout,
164
- execFile = childProcess.execFileSync,
106
+ // `ps` seam for cleanStale's identity check; undefined keeps
107
+ // process-identity.js's real execFileSync, so this module runs no `ps` itself.
108
+ execFile,
165
109
  // Structured observability, injected as a FACTORY rather than as a ready-made
166
110
  // recorder. See the binding below for why it has to be a factory.
167
111
  //
@@ -296,12 +240,31 @@ async function startDaemon({
296
240
  safeUnlink(lockFile);
297
241
  }
298
242
 
243
+ // Engine liveness (#156). `serving` flips once the daemon is listening and
244
+ // about to write its handle. Before that an engine exit only clears
245
+ // `engineAlive` — the check after listen() below turns it into a failed start,
246
+ // so no handle is ever written for a dead engine. After it, an engine exit
247
+ // reaps the daemon: recorded first and synchronously, like the crash guards,
248
+ // because stop() can block on the in-flight drain. stop() is the existing path
249
+ // unchanged: drain, destroy sockets, close the server, close() (a no-op on a
250
+ // dead engine), unlink, release the lock, resolve whenStopped. The next verb
251
+ // autostarts a fresh daemon.
252
+ let engineAlive = true;
253
+ let serving = false;
254
+ function onEngineExit() {
255
+ if (!engineAlive) return;
256
+ engineAlive = false;
257
+ if (!serving) return;
258
+ safeObserve({ level: 'error', event: 'daemon.engine_exit', dur_ms: Date.now() - startedAtMs });
259
+ stop('engine_exit').catch(() => {});
260
+ }
261
+
299
262
  // Build the single shared connection up front. If it rejects, never leak the
300
263
  // lock (and the child it may have started is the connection's own to clean).
301
264
  let call;
302
265
  let close;
303
266
  try {
304
- ({ call, close } = await createCall({ device }));
267
+ ({ call, close } = await createCall({ device, onEngineExit }));
305
268
  } catch (err) {
306
269
  // #156's core symptom: mobile-mcp never came up, and until now the only
307
270
  // trace was a 15s readiness timeout in the spawning verb.
@@ -599,7 +562,11 @@ async function startDaemon({
599
562
  continue;
600
563
  }
601
564
  if (req.type === 'ping') {
602
- await reply({ id: req.id, ok: true, result: { pong: true, device, in_flight: inFlight } });
565
+ await reply({
566
+ id: req.id,
567
+ ok: true,
568
+ result: { pong: true, device, in_flight: inFlight, engine_alive: engineAlive },
569
+ });
603
570
  armIdle();
604
571
  continue;
605
572
  }
@@ -661,25 +628,23 @@ async function startDaemon({
661
628
 
662
629
  const socketPath = paths.socketPath(projectRoot);
663
630
 
664
- try {
665
- await new Promise((resolve, reject) => {
666
- server.once('error', reject);
667
- server.listen(socketPath, () => {
668
- server.removeListener('error', reject);
669
- resolve();
670
- });
671
- });
672
- } catch (err) {
673
- // listen failed (e.g. EADDRINUSE from a double-spawn that beat us to the
674
- // bind, or a recycled-pid wedge). The connection was already built — close
675
- // it so we never leak the mobile-mcp child, and release the lock.
631
+ // A start that fails once the connection exists: record why, undo what was
632
+ // built (the listening socket, if any, then the engine), release the lock, and
633
+ // rethrow — so a failed start never leaks the mobile-mcp child or wedges the
634
+ // next spawn behind a held lock. The connect failure above has no `close` yet
635
+ // and so does its own, smaller teardown.
636
+ async function abortStart({ event, err, listening }) {
676
637
  safeObserve({
677
638
  level: 'error',
678
- event: 'daemon.listen_failure',
639
+ event,
679
640
  error_code: err && err.code,
680
641
  message: err && (err.message || String(err)),
681
642
  dur_ms: Date.now() - startedAtMs,
682
643
  });
644
+ if (listening) {
645
+ await new Promise((resolve) => server.close(() => resolve()));
646
+ safeUnlink(socketPath);
647
+ }
683
648
  try {
684
649
  await close();
685
650
  } catch (_) {
@@ -689,6 +654,35 @@ async function startDaemon({
689
654
  throw err;
690
655
  }
691
656
 
657
+ try {
658
+ await new Promise((resolve, reject) => {
659
+ server.once('error', reject);
660
+ server.listen(socketPath, () => {
661
+ server.removeListener('error', reject);
662
+ resolve();
663
+ });
664
+ });
665
+ } catch (err) {
666
+ // listen failed (e.g. EADDRINUSE from a double-spawn that beat us to the
667
+ // bind, or a recycled-pid wedge). Nothing is listening, so only the engine
668
+ // and the lock are undone.
669
+ await abortStart({ event: 'daemon.listen_failure', err, listening: false });
670
+ }
671
+
672
+ // The engine died between connect and listen. Fail the start exactly like a
673
+ // listen failure — before the handle exists, so nothing reading it is told a
674
+ // dead session is ready. The bin's main().catch exits 1; the spawner sees a
675
+ // start that never became ready.
676
+ if (!engineAlive) {
677
+ const err = new Error('mobile-mcp exited before the device session daemon was ready');
678
+ err.code = 'ENGINE_EXITED';
679
+ await abortStart({ event: 'daemon.connect_failure', err, listening: true });
680
+ }
681
+
682
+ // Serving from here on: an engine exit reaps the daemon (see onEngineExit).
683
+ // Set synchronously after the check above, so no exit can fall between them.
684
+ serving = true;
685
+
692
686
  // Persist handle + pidfile now that we are actually listening.
693
687
  const handle = {
694
688
  pid: process.pid,
@@ -741,4 +735,9 @@ async function startDaemon({
741
735
  };
742
736
  }
743
737
 
744
- module.exports = { startDaemon, cleanStale, pidAlive, pidIdentity, DAEMON_CALL_TIMEOUT_MS, DEFAULT_IDLE_MS };
738
+ module.exports = {
739
+ startDaemon,
740
+ cleanStale,
741
+ DAEMON_CALL_TIMEOUT_MS,
742
+ DEFAULT_IDLE_MS,
743
+ };