mobile-automator 0.27.1 → 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/package.json +1 -1
- package/src/cli.js +43 -6
- package/src/device/connection.js +72 -3
- package/src/device/mobile-mcp-client.js +105 -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/observe/event.js +2 -2
- package/src/observe/vocab.js +15 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mobile-automator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0",
|
|
4
4
|
"description": "Host-agnostic mauto CLI for AI-driven mobile QA automation — analyzes a mobile project and drives devices through mobile-mcp.",
|
|
5
5
|
"homepage": "https://github.com/sh3lan93/mobile-automator#readme",
|
|
6
6
|
"repository": {
|
package/src/cli.js
CHANGED
|
@@ -1234,17 +1234,54 @@ async function handleSessionStatus(
|
|
|
1234
1234
|
{ projectRoot, client = require('./device/session-client') } = {}
|
|
1235
1235
|
) {
|
|
1236
1236
|
const status = await connection.sessionStatus(projectRoot, { client });
|
|
1237
|
-
|
|
1237
|
+
const hint =
|
|
1238
|
+
status.reason === 'engine_exited'
|
|
1239
|
+
? `The device engine exited; this session is shutting down and the next verb starts a fresh one. ${connection.daemonLogHint(projectRoot)}`
|
|
1240
|
+
: null;
|
|
1241
|
+
return { envelope: ok(status, hint), exitKind: 'ok' };
|
|
1238
1242
|
}
|
|
1239
1243
|
|
|
1244
|
+
// endSession's failure outcomes (#156), each with the hint that is true for it.
|
|
1245
|
+
// All three may leave a daemon alive and holding the workspace lock; what
|
|
1246
|
+
// differs is WHY, and so what the user should do about it. One vague hint used
|
|
1247
|
+
// to cover all three.
|
|
1248
|
+
const SESSION_END_FAILURE_HINTS = {
|
|
1249
|
+
survived: (pid, pidFile) =>
|
|
1250
|
+
`It is still alive after SIGTERM and SIGKILL and may still hold the workspace lock. Check it with \`ps -p ${pid}\` and stop it with \`kill -9 ${pid}\` (pidfile: ${pidFile}).`,
|
|
1251
|
+
foreign: (pid, pidFile) =>
|
|
1252
|
+
`It answered the shutdown, but its command line does not name this workspace (possibly started by mauto < 0.28), so it was not signalled. Check it with \`ps -p ${pid}\` and stop it manually (pidfile: ${pidFile}).`,
|
|
1253
|
+
unverified: (pid, pidFile) =>
|
|
1254
|
+
`It could not be identified (\`ps\` failed), so it was not signalled. Check it with \`ps -p ${pid}\` and stop it manually if it is this workspace's mauto daemon (pidfile: ${pidFile}).`,
|
|
1255
|
+
};
|
|
1256
|
+
|
|
1240
1257
|
async function handleSessionEnd(
|
|
1241
|
-
{ projectRoot, client = require('./device/session-client') } = {}
|
|
1258
|
+
{ projectRoot, client = require('./device/session-client'), end = {} } = {}
|
|
1242
1259
|
) {
|
|
1243
|
-
const
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1260
|
+
const { outcome, pid } = await connection.endSession(projectRoot, { client, ...end });
|
|
1261
|
+
const logHint = connection.daemonLogHint(projectRoot);
|
|
1262
|
+
if (Object.prototype.hasOwnProperty.call(SESSION_END_FAILURE_HINTS, outcome)) {
|
|
1263
|
+
const pidFile = require('./device/session-paths').pidFilePath(projectRoot);
|
|
1264
|
+
return {
|
|
1265
|
+
envelope: fail(
|
|
1266
|
+
'device',
|
|
1267
|
+
`device session daemon pid ${pid} did not exit`,
|
|
1268
|
+
`${SESSION_END_FAILURE_HINTS[outcome](pid, pidFile)} ${logHint}`,
|
|
1269
|
+
{ stopped: false, already_stopped: false, pid }
|
|
1270
|
+
),
|
|
1271
|
+
exitKind: 'device',
|
|
1272
|
+
};
|
|
1273
|
+
}
|
|
1274
|
+
// 'stopped' | 'forced' | 'already_stopped'. The ok envelope keeps the boolean
|
|
1275
|
+
// shape it had before endSession returned an outcome.
|
|
1276
|
+
const data = {
|
|
1277
|
+
stopped: outcome === 'stopped' || outcome === 'forced',
|
|
1278
|
+
already_stopped: outcome === 'already_stopped',
|
|
1279
|
+
forced: outcome === 'forced',
|
|
1247
1280
|
};
|
|
1281
|
+
const hint = outcome === 'forced'
|
|
1282
|
+
? `The daemon did not exit after shutdown; terminated pid ${pid}. ${logHint}`
|
|
1283
|
+
: null;
|
|
1284
|
+
return { envelope: ok(data, hint), exitKind: 'ok' };
|
|
1248
1285
|
}
|
|
1249
1286
|
|
|
1250
1287
|
// --- Issue #92: device discovery + persisted selection -------------------
|
package/src/device/connection.js
CHANGED
|
@@ -19,6 +19,9 @@ const { daemonLogHint } = require('./session-log');
|
|
|
19
19
|
const paths = require('./session-paths');
|
|
20
20
|
const sessionClient = require('./session-client');
|
|
21
21
|
const sessionSpawn = require('./session-spawn');
|
|
22
|
+
const sessionDaemon = require('./session-daemon');
|
|
23
|
+
const processIdentity = require('./process-identity');
|
|
24
|
+
const processReaper = require('./process-reaper');
|
|
22
25
|
|
|
23
26
|
// Acquire a device connection, transparently reusing a live daemon when one
|
|
24
27
|
// fits and falling back to a one-shot mobile-mcp spawn otherwise. Returns
|
|
@@ -63,6 +66,8 @@ async function sessionStatus(projectRoot, { client = sessionClient } = {}) {
|
|
|
63
66
|
const status = await client.getSessionStatus(projectRoot);
|
|
64
67
|
return {
|
|
65
68
|
...status,
|
|
69
|
+
// Always present (#156): null, or why a reachable daemon is not running.
|
|
70
|
+
reason: (status && status.reason) || null,
|
|
66
71
|
log_path: paths.logFilePath(projectRoot),
|
|
67
72
|
// Read from the handle, not from the ping reply. The handle is written once
|
|
68
73
|
// the daemon is listening and removed by stop(), so it is the same fact
|
|
@@ -82,9 +87,72 @@ function startSession({ projectRoot, device = null, idleMs, spawn = sessionSpawn
|
|
|
82
87
|
return spawn.spawnDaemon({ projectRoot, device, idleMs });
|
|
83
88
|
}
|
|
84
89
|
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
90
|
+
// How long `session end` waits for an acknowledged daemon to exit before it
|
|
91
|
+
// treats it as stuck: stop() drains in-flight calls (bounded by the daemon call
|
|
92
|
+
// timeout) and then closes the engine (the SDK's ~4s ladder). Derived, not
|
|
93
|
+
// restated; pinned by tests/unit/device/timeout-invariant.test.js.
|
|
94
|
+
const END_BUDGET_MS = sessionDaemon.DAEMON_CALL_TIMEOUT_MS + 10000;
|
|
95
|
+
|
|
96
|
+
// Stop the daemon and report only what is true (#156). The daemon acks the
|
|
97
|
+
// shutdown frame BEFORE its teardown runs, so an ack is a promise, not a fact:
|
|
98
|
+
// 'stopped' is reported only once the pid is gone.
|
|
99
|
+
//
|
|
100
|
+
// Returns { outcome, pid } — ONE discriminated answer, so a caller dispatches
|
|
101
|
+
// on what happened instead of re-deriving it from a bag of booleans:
|
|
102
|
+
// 'stopped' - acked, and the pid exited (or there was no pid to watch)
|
|
103
|
+
// 'already_stopped' - nothing acked and nothing of ours was running
|
|
104
|
+
// 'forced' - ours, stuck in teardown, terminated by a group kill
|
|
105
|
+
// 'survived' - ours, stuck, and still alive after SIGTERM + SIGKILL
|
|
106
|
+
// 'foreign' - acked, but the live pid's command line does not name
|
|
107
|
+
// this workspace, so it was reported, never signalled
|
|
108
|
+
// 'unverified' - the pid could not be identified (`ps` failed), so it
|
|
109
|
+
// was never signalled
|
|
110
|
+
// The last three are failures: a daemon may still be alive.
|
|
111
|
+
async function endSession(
|
|
112
|
+
projectRoot,
|
|
113
|
+
{
|
|
114
|
+
client = sessionClient,
|
|
115
|
+
reaper = processReaper,
|
|
116
|
+
// Bound to THIS workspace (#156): a group kill must never reach another
|
|
117
|
+
// workspace's daemon through a copied or recycled pidfile.
|
|
118
|
+
identify = (pid) => processIdentity.pidIdentity(pid, { projectRoot }),
|
|
119
|
+
readPid = () => processIdentity.readPidFile(paths.pidFilePath(projectRoot)),
|
|
120
|
+
budgetMs = END_BUDGET_MS,
|
|
121
|
+
} = {}
|
|
122
|
+
) {
|
|
123
|
+
// Read BEFORE asking: a daemon that exits cleanly removes its pidfile, and we
|
|
124
|
+
// need to know whom to watch.
|
|
125
|
+
const pid = readPid();
|
|
126
|
+
const acked = await client.requestShutdown(projectRoot);
|
|
127
|
+
const report = (outcome) => ({ outcome, pid: pid || null });
|
|
128
|
+
// The daemon is gone: it stopped because we asked, or was never running.
|
|
129
|
+
const gone = () => report(acked ? 'stopped' : 'already_stopped');
|
|
130
|
+
|
|
131
|
+
// No pidfile: nothing to watch, so the ack is all we have (as before). Our
|
|
132
|
+
// own pid: the in-process daemon of a test — never wait on it, never signal it.
|
|
133
|
+
if (!pid || pid === process.pid) return gone();
|
|
134
|
+
|
|
135
|
+
// A non-acking daemon whose pidfile names a live process is checked at once
|
|
136
|
+
// (budget 0): either it is gone, or it is stuck too badly to answer.
|
|
137
|
+
if (await reaper.waitForDeath(pid, { budgetMs: acked ? budgetMs : 0 })) return gone();
|
|
138
|
+
|
|
139
|
+
switch (identify(pid)) {
|
|
140
|
+
case 'dead':
|
|
141
|
+
case 'other': // the pid was recycled: our daemon is gone
|
|
142
|
+
return gone();
|
|
143
|
+
case 'foreign':
|
|
144
|
+
// A mauto daemon whose command line does not name this workspace:
|
|
145
|
+
// another workspace's, reached through a copied or recycled pidfile.
|
|
146
|
+
// Never signalled. Without an ack nothing answered on OUR socket, so none
|
|
147
|
+
// of ours was running. With an ack the daemon that answered is ours, but
|
|
148
|
+
// its command line cannot prove it (one started before the workspace
|
|
149
|
+
// marker), so it is reported rather than killed.
|
|
150
|
+
return report(acked ? 'foreign' : 'already_stopped');
|
|
151
|
+
case 'ours':
|
|
152
|
+
return report((await reaper.terminate(pid, { group: true })) === 'dead' ? 'forced' : 'survived');
|
|
153
|
+
default: // 'unknown': never signal a process we cannot identify
|
|
154
|
+
return report('unverified');
|
|
155
|
+
}
|
|
88
156
|
}
|
|
89
157
|
|
|
90
158
|
module.exports = {
|
|
@@ -97,4 +165,5 @@ module.exports = {
|
|
|
97
165
|
sessionStatus,
|
|
98
166
|
startSession,
|
|
99
167
|
endSession,
|
|
168
|
+
END_BUDGET_MS,
|
|
100
169
|
};
|
|
@@ -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
|
|
@@ -162,4 +248,11 @@ function parseToolResult(res) {
|
|
|
162
248
|
|
|
163
249
|
// resolveServerEntry is exported so tests/integration/mobile-mcp-contract.test.js
|
|
164
250
|
// spawns the pinned engine by the exact path production does, not a copy of it.
|
|
165
|
-
module.exports = {
|
|
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
|
+
};
|
|
@@ -74,6 +74,26 @@ function lockPath(projectRoot) {
|
|
|
74
74
|
return path.join(sessionDir(projectRoot), LOCK_NAME);
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
// The workspace a daemon serves, as it appears on its command line (#156).
|
|
78
|
+
// The daemon itself reads its root from env (MAUTO_SESSION_PROJECT_ROOT), which
|
|
79
|
+
// `ps` cannot see, so every daemon's command line used to be identical. This
|
|
80
|
+
// marker is what lets `session end` tell THIS workspace's daemon from another
|
|
81
|
+
// workspace's before it signals a process group: a copied workspace (`cp -R`
|
|
82
|
+
// carries daemon.pid) or a recycled pid can name a different live daemon.
|
|
83
|
+
// The root is the LAST argument so a match can be anchored at the end.
|
|
84
|
+
const DAEMON_ROOT_FLAG = '--project-root';
|
|
85
|
+
|
|
86
|
+
function daemonArgv(projectRoot) {
|
|
87
|
+
return [DAEMON_ROOT_FLAG, path.resolve(projectRoot)];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Does a `ps -o command=` line name exactly this workspace? Anchored at the end
|
|
91
|
+
// of the line so `/a/proj` never matches a daemon serving `/a/proj2`.
|
|
92
|
+
function commandNamesWorkspace(command, projectRoot) {
|
|
93
|
+
const line = String(command || '').replace(/\s+$/, '');
|
|
94
|
+
return line.endsWith(` ${DAEMON_ROOT_FLAG} ${path.resolve(projectRoot)}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
77
97
|
module.exports = {
|
|
78
98
|
SESSION_DIRNAME,
|
|
79
99
|
SOCKET_NAME,
|
|
@@ -89,4 +109,7 @@ module.exports = {
|
|
|
89
109
|
pidFilePath,
|
|
90
110
|
handlePath,
|
|
91
111
|
lockPath,
|
|
112
|
+
DAEMON_ROOT_FLAG,
|
|
113
|
+
daemonArgv,
|
|
114
|
+
commandNamesWorkspace,
|
|
92
115
|
};
|
|
@@ -12,6 +12,8 @@ const childProcess = require('child_process');
|
|
|
12
12
|
|
|
13
13
|
const sessionClient = require('./session-client');
|
|
14
14
|
const sessionLog = require('./session-log');
|
|
15
|
+
const paths = require('./session-paths');
|
|
16
|
+
const reaper = require('./process-reaper');
|
|
15
17
|
|
|
16
18
|
const DAEMON_BIN = path.join(__dirname, '..', '..', 'bin', 'mauto-session-daemon.js');
|
|
17
19
|
const DEFAULT_READY_TIMEOUT_MS = 15000;
|
|
@@ -31,6 +33,10 @@ async function spawnDaemon({
|
|
|
31
33
|
pollMs = DEFAULT_POLL_MS,
|
|
32
34
|
isAlive = sessionClient.isAlive,
|
|
33
35
|
openLog = sessionLog.openDaemonLog,
|
|
36
|
+
// Kills a daemon that never became ready (#156). group:true reaches the
|
|
37
|
+
// daemon AND its mobile-mcp engine: `detached: true` below makes the daemon
|
|
38
|
+
// a process-group leader, and the engine is spawned into its group.
|
|
39
|
+
terminate = (pid) => reaper.terminate(pid, { group: true }),
|
|
34
40
|
} = {}) {
|
|
35
41
|
if (!projectRoot) throw new TypeError('spawnDaemon requires projectRoot');
|
|
36
42
|
|
|
@@ -46,7 +52,9 @@ async function spawnDaemon({
|
|
|
46
52
|
const log = openLog(projectRoot);
|
|
47
53
|
|
|
48
54
|
try {
|
|
49
|
-
|
|
55
|
+
// The root in argv is an identity marker only (see paths.daemonArgv): env
|
|
56
|
+
// stays the daemon's source of truth, but `ps` can only see argv.
|
|
57
|
+
const child = spawn(process.execPath, [DAEMON_BIN, ...paths.daemonArgv(projectRoot)], {
|
|
50
58
|
detached: true,
|
|
51
59
|
stdio: log.stdio,
|
|
52
60
|
env,
|
|
@@ -80,6 +88,18 @@ async function spawnDaemon({
|
|
|
80
88
|
if (await isAlive(projectRoot)) return true;
|
|
81
89
|
await sleep(pollMs);
|
|
82
90
|
}
|
|
91
|
+
|
|
92
|
+
// Not ready in time. Without this the child lives on — typically still
|
|
93
|
+
// inside createCall, holding the O_EXCL lock — and every later verb burns
|
|
94
|
+
// its own readiness window against it (#156). Only OUR child, and only if
|
|
95
|
+
// it is still running: an ELOCKED spawn-race loser has already exited and
|
|
96
|
+
// must never signal anything. Node tracked this exact child, so its state
|
|
97
|
+
// needs no pidIdentity check and has no pid-reuse window.
|
|
98
|
+
if (child && child.pid && child.exitCode === null && child.signalCode === null) {
|
|
99
|
+
const outcome = await terminate(child.pid);
|
|
100
|
+
// The parent is the only witness to this kill — write it down (#163).
|
|
101
|
+
log.write(`mauto: daemon not ready after ${readyTimeoutMs}ms; terminated pid=${child.pid} (${outcome})\n`);
|
|
102
|
+
}
|
|
83
103
|
return false;
|
|
84
104
|
} finally {
|
|
85
105
|
// The child holds its own dup, so the parent releases its descriptor rather
|
|
@@ -89,4 +109,4 @@ async function spawnDaemon({
|
|
|
89
109
|
}
|
|
90
110
|
}
|
|
91
111
|
|
|
92
|
-
module.exports = { spawnDaemon, DAEMON_BIN };
|
|
112
|
+
module.exports = { spawnDaemon, DAEMON_BIN, DEFAULT_READY_TIMEOUT_MS };
|
package/src/observe/event.js
CHANGED
|
@@ -120,10 +120,10 @@ const EVENT_FIELDS = {
|
|
|
120
120
|
// src/device/mobile-mcp-tools.js. A daemon-minted counter needs no redaction
|
|
121
121
|
// argument at all, on exactly the grounds dur_ms is cleared.
|
|
122
122
|
call_id: sendable(integer({ min: 1 }), 'daemon-minted counter scoped to one session; never the client-supplied frame id'),
|
|
123
|
-
stop_reason: sendable(oneOf(STOP_REASONS), 'enumerated: idle|signal|shutdown|crash|explicit'),
|
|
123
|
+
stop_reason: sendable(oneOf(STOP_REASONS), 'enumerated: idle|signal|shutdown|crash|explicit|engine_exit'),
|
|
124
124
|
// Errno NAMES only: the numeric codes an MCP server reports, and Node's
|
|
125
125
|
// ERR_* codes, fail the check and are dropped rather than sent.
|
|
126
|
-
error_code: sendable(oneOf(ERROR_CODES), 'Node/libuv errno string (EADDRINUSE|EACCES|…) plus our own ELOCKED'),
|
|
126
|
+
error_code: sendable(oneOf(ERROR_CODES), 'Node/libuv errno string (EADDRINUSE|EACCES|…) plus our own ELOCKED|ENGINE_CONNECT_TIMEOUT|ENGINE_EXITED'),
|
|
127
127
|
|
|
128
128
|
// --- crash (slice 4) ---------------------------------------------------
|
|
129
129
|
// The ONLY new field slice 4 mints. As with `verb`, `tool` and `session_id`,
|
package/src/observe/vocab.js
CHANGED
|
@@ -43,6 +43,7 @@ const EVENT_NAMES = Object.freeze([
|
|
|
43
43
|
'daemon.start_failure',
|
|
44
44
|
'daemon.listen_failure',
|
|
45
45
|
'daemon.connect_failure',
|
|
46
|
+
'daemon.engine_exit',
|
|
46
47
|
'daemon.lock_conflict',
|
|
47
48
|
'daemon.undeliverable',
|
|
48
49
|
// Emitted by the flush path, which lands in a later slice-5 task; declared
|
|
@@ -86,11 +87,20 @@ const VERB_NAMES = Object.freeze([
|
|
|
86
87
|
]);
|
|
87
88
|
|
|
88
89
|
// Why the daemon stopped. 'crash' is documented but not yet produced.
|
|
89
|
-
|
|
90
|
+
// 'engine_exit': the mobile-mcp engine died under a serving daemon (#156).
|
|
91
|
+
const STOP_REASONS = Object.freeze(['idle', 'signal', 'shutdown', 'crash', 'explicit', 'engine_exit']);
|
|
90
92
|
|
|
91
|
-
// Node/libuv errno names for this platform, plus our own
|
|
92
|
-
// conflict,
|
|
93
|
-
//
|
|
94
|
-
|
|
93
|
+
// Node/libuv errno names for this platform, plus our own codes, all recorded by
|
|
94
|
+
// src/device/: ELOCKED (a lock conflict, session-daemon.js),
|
|
95
|
+
// ENGINE_CONNECT_TIMEOUT (initialize exceeded its bound, mobile-mcp-client.js)
|
|
96
|
+
// and ENGINE_EXITED (the engine died before the daemon was serving,
|
|
97
|
+
// session-daemon.js). Names only: the numeric codes an MCP server reports are
|
|
98
|
+
// not in this set and so never pass.
|
|
99
|
+
const ERROR_CODES = Object.freeze([
|
|
100
|
+
...Object.keys(os.constants.errno),
|
|
101
|
+
'ELOCKED',
|
|
102
|
+
'ENGINE_CONNECT_TIMEOUT',
|
|
103
|
+
'ENGINE_EXITED',
|
|
104
|
+
]);
|
|
95
105
|
|
|
96
106
|
module.exports = { OS_NAMES, EVENT_NAMES, VERB_NAMES, STOP_REASONS, ERROR_CODES };
|