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.
- package/README.md +2 -2
- package/package.json +3 -3
- package/src/cli.js +75 -14
- package/src/device/action-catalog.js +2 -2
- package/src/device/bridge.js +36 -9
- package/src/device/connection.js +72 -3
- package/src/device/device-resolver.js +2 -1
- package/src/device/element-model.js +37 -15
- package/src/device/engine-hints.js +83 -0
- package/src/device/mobile-mcp-client.js +107 -12
- package/src/device/process-identity.js +124 -0
- package/src/device/process-reaper.js +123 -0
- package/src/device/session-client.js +11 -5
- package/src/device/session-daemon.js +75 -76
- package/src/device/session-paths.js +23 -0
- package/src/device/session-spawn.js +22 -2
- package/src/device/swipe-geometry.js +39 -0
- package/src/device/tool-args.js +3 -1
- package/src/observe/event.js +2 -2
- package/src/observe/vocab.js +15 -5
|
@@ -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
|
-
//
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
164
|
+
if (!(res && res.ok)) return down;
|
|
160
165
|
const result = res.result || {};
|
|
161
|
-
|
|
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
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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
|
|
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 = {
|
|
738
|
+
module.exports = {
|
|
739
|
+
startDaemon,
|
|
740
|
+
cleanStale,
|
|
741
|
+
DAEMON_CALL_TIMEOUT_MS,
|
|
742
|
+
DEFAULT_IDLE_MS,
|
|
743
|
+
};
|