mobile-automator 0.23.9 → 0.24.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.
@@ -46,6 +46,15 @@ function cleanupWorkspaceIfOwned(projectRoot) {
46
46
  }
47
47
 
48
48
  async function main() {
49
+ // First write of the process, so every later line in the shared append-only
50
+ // log is attributable to a spawn. Written HERE rather than by the spawning
51
+ // parent because only this process knows its own pid, and because a parent
52
+ // banner races the child's own first output (#163). Under the degraded
53
+ // `stdio: 'ignore'` handle this lands in /dev/null, which is correct.
54
+ process.stderr.write(
55
+ `\n=== mauto daemon ${new Date().toISOString()} pid=${process.pid} ===\n`
56
+ );
57
+
49
58
  const projectRoot = process.env.MAUTO_SESSION_PROJECT_ROOT;
50
59
  if (!projectRoot) {
51
60
  process.stderr.write('mauto-session-daemon: MAUTO_SESSION_PROJECT_ROOT is required\n');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobile-automator",
3
- "version": "0.23.9",
3
+ "version": "0.24.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
@@ -995,8 +995,13 @@ async function handleSessionStart(
995
995
 
996
996
  const started = await connection.startSession({ projectRoot, device, idleMs, spawn });
997
997
  if (!started) {
998
+ // The daemon's stdout/stderr are captured to the workspace log, so the
999
+ // "why" of a startup crash exists on disk — but only this hint tells the
1000
+ // user (or agent) that it does. Same sentence the transparent-autostart
1001
+ // fallback uses, so the log is named identically wherever it surfaces.
1002
+ const logHint = connection.daemonLogHint(projectRoot);
998
1003
  return {
999
- envelope: fail('device', 'failed to start the device session daemon', 'Ensure a device or simulator is connected, or run verbs directly (they fall back to one-shot).'),
1004
+ envelope: fail('device', 'failed to start the device session daemon', `Ensure a device or simulator is connected, or run verbs directly (they fall back to one-shot). ${logHint}`),
1000
1005
  exitKind: 'device',
1001
1006
  };
1002
1007
  }
@@ -14,6 +14,8 @@
14
14
  // handlers.
15
15
 
16
16
  const { resolveDeviceConnection } = require('./resolve-connection');
17
+ const { daemonLogHint } = require('./session-log');
18
+ const paths = require('./session-paths');
17
19
  const sessionClient = require('./session-client');
18
20
  const sessionSpawn = require('./session-spawn');
19
21
 
@@ -40,10 +42,20 @@ function isSessionAlive(projectRoot, { client = sessionClient } = {}) {
40
42
  return client.isAlive(projectRoot);
41
43
  }
42
44
 
43
- // Rich status: running + in-flight call count + pinned device. `null`s when not
44
- // running. The in-flight count exposes the double-execution window.
45
- function sessionStatus(projectRoot, { client = sessionClient } = {}) {
46
- return client.getSessionStatus(projectRoot);
45
+ // Rich status: running + in-flight call count + pinned device (`null`s when not
46
+ // running), plus log_path, which is reported unconditionally. The in-flight
47
+ // count exposes the double-execution window.
48
+ //
49
+ // log_path is computed HERE from the project root rather than read out of the
50
+ // daemon's ping reply, and that is deliberate: getSessionStatus() collapses
51
+ // every failure branch — no socket, non-ok ping, or a throw — into the same
52
+ // not-running shape, so a daemon-supplied path would go missing in exactly the
53
+ // case where a user is reaching for `session status` to find out why the daemon
54
+ // is dead. The path is a pure function of the workspace, so we can always
55
+ // answer it. Do not "simplify" this into the ping payload.
56
+ async function sessionStatus(projectRoot, { client = sessionClient } = {}) {
57
+ const status = await client.getSessionStatus(projectRoot);
58
+ return { ...status, log_path: paths.logFilePath(projectRoot) };
47
59
  }
48
60
 
49
61
  // Spawn a daemon and wait until it answers. Resolves true on success.
@@ -58,6 +70,10 @@ function endSession(projectRoot, { client = sessionClient } = {}) {
58
70
 
59
71
  module.exports = {
60
72
  acquireConnection,
73
+ // Re-exported from session-log so cli.js can name the daemon log through the
74
+ // one device facade it already imports, instead of reaching into a
75
+ // connection-strategy module for a string.
76
+ daemonLogHint,
61
77
  isSessionAlive,
62
78
  sessionStatus,
63
79
  startSession,
@@ -14,7 +14,9 @@
14
14
  // (c) autostart && no reachable daemon
15
15
  // -> spawn a daemon, then connect daemon-backed (no-op close).
16
16
  // (d) spawn failed, or autostart:false
17
- // -> one-shot fallback via the real createCall (real close).
17
+ // -> one-shot fallback via the real createCall (real close). When the
18
+ // spawn is what failed, a thrown one-shot error is decorated with a
19
+ // hint naming the daemon log so the crash stays findable (#163).
18
20
  //
19
21
  // Everything device-touching is injected so the whole matrix is unit-testable
20
22
  // with a fake daemon + fake createCall + fake spawn.
@@ -24,6 +26,7 @@ const fs = require('fs');
24
26
  const { DeviceBridge } = require('./bridge');
25
27
  const paths = require('./session-paths');
26
28
  const sessionClient = require('./session-client');
29
+ const { daemonLogHint } = require('./session-log');
27
30
  const sessionSpawn = require('./session-spawn');
28
31
 
29
32
  function defaultCreateCall(opts) {
@@ -135,17 +138,34 @@ async function resolveDeviceConnection({
135
138
  requestedDevice: device,
136
139
  autostart,
137
140
  });
141
+ let spawnFailed = false;
138
142
  if (strategy === 'spawn-then-daemon') {
139
143
  // (c) spawn a daemon, then connect daemon-backed.
140
144
  const started = await spawn.spawnDaemon({ projectRoot, device, idleMs });
141
145
  if (started) {
142
146
  const conn = await daemonBacked();
143
147
  if (conn) return conn;
148
+ // Spawn worked; the daemon just wasn't reachable. Its log has nothing to
149
+ // say about a subsequent one-shot failure, so no hint — fall through.
150
+ } else {
151
+ spawnFailed = true;
144
152
  }
145
153
  }
146
154
 
147
- // (d) explicit one-shot, or spawn failed / unreachable.
148
- return oneShot();
155
+ // (d) explicit one-shot, or spawn failed / unreachable. When the SPAWN is
156
+ // what failed, the daemon's log is the only record of why, and transparent
157
+ // autostart is the common path — most users never type `mauto session start`,
158
+ // so without this the log stays invisible to almost everyone. The signal
159
+ // rides err.hint because deviceFail() in cli.js already surfaces it on every
160
+ // device verb, so acquireConnection's deliberately narrow contract is
161
+ // untouched (#163).
162
+ try {
163
+ return await oneShot();
164
+ } catch (err) {
165
+ // Never clobber a more specific hint the transport already attached.
166
+ if (spawnFailed && !err.hint) err.hint = daemonLogHint(projectRoot);
167
+ throw err;
168
+ }
149
169
  }
150
170
 
151
171
  module.exports = {
@@ -225,7 +225,11 @@ async function startDaemon({
225
225
  `mauto-session-daemon: undeliverable reply id=${info.id}: ${info.reason}\n`
226
226
  );
227
227
  } catch (_) {
228
- /* stderr gone */
228
+ // fd 2 is unwritable (full disk, or the workspace log could not be
229
+ // opened and this is /dev/null's successor). Since #163 it normally
230
+ // reaches mobile-automator/.session/daemon.log; either way an
231
+ // undeliverable reply is already recorded in `undeliverable`, so
232
+ // losing the warning must not take the daemon down.
229
233
  }
230
234
  };
231
235
 
@@ -0,0 +1,95 @@
1
+ 'use strict';
2
+
3
+ // Lifecycle of the daemon's log file, so session-spawn.js can stay
4
+ // filesystem-free and just hand the stdio triple to child_process.spawn.
5
+ //
6
+ // Failure policy is PER-STEP, not whole-module. Rotation is a disk guard;
7
+ // the diagnostics are the point. A rotation that cannot run — a race with a
8
+ // concurrent spawn, a permissions problem — must not cost us the log. Only an
9
+ // unopenable log degrades, and it degrades to today's `stdio: 'ignore'`:
10
+ // a read-only or full workspace loses diagnostics, never the CLI.
11
+
12
+ const realFs = require('fs');
13
+
14
+ const { sessionDir, logFilePath } = require('./session-paths');
15
+
16
+ const MAX_LOG_BYTES = 1024 * 1024;
17
+
18
+ // The degraded handle, expressed as a VALUE so callers never branch on null.
19
+ // `stdio: 'ignore'` is the behaviour that predates #163.
20
+ const IGNORED = Object.freeze({ stdio: 'ignore', write() {}, close() {} });
21
+
22
+ // The one sentence pointing a user at the daemon's captured output (#163).
23
+ // It lives here because it is a fact about the log, and it is re-exported
24
+ // through connection.js so cli.js reaches it via the device facade it already
25
+ // imports rather than reaching into a connection-strategy module.
26
+ function daemonLogHint(projectRoot) {
27
+ return `The daemon's output (including stack traces) was captured to ${logFilePath(projectRoot)}.`;
28
+ }
29
+
30
+ // Best-effort rotation to a single previous generation; clobbering an existing
31
+ // .1 is the intended bound. Every failure here is survivable — no log yet
32
+ // (ENOENT on stat), a concurrent spawn that already rotated (ENOENT on rename),
33
+ // an unreadable directory (EACCES) — so we swallow and carry on to the open.
34
+ // An oversized log beats no log.
35
+ function rotateIfLarge(logPath, maxBytes, fs) {
36
+ try {
37
+ if (fs.statSync(logPath).size >= maxBytes) fs.renameSync(logPath, `${logPath}.1`);
38
+ } catch (_) {
39
+ /* nothing to rotate, or someone beat us to it */
40
+ }
41
+ }
42
+
43
+ // Returns { stdio, write, close } — NEVER null.
44
+ //
45
+ // stdio the triple for child_process.spawn. stdout AND stderr point at the
46
+ // same fd on purpose: the child gets dups sharing one O_APPEND file
47
+ // description, so the two streams interleave without clobbering. That
48
+ // one descriptor also captures mobile-mcp, whose MCP stdio transport
49
+ // defaults its stderr to 'inherit' and therefore writes to whatever
50
+ // fd 2 the daemon has. Point fd 2 at /dev/null and its crashes vanish.
51
+ // write for diagnostics only the PARENT can see. A spawn that fails to exec
52
+ // leaves the child with nothing to say, so this is the sole record of
53
+ // EMFILE/EACCES/ENOENT-on-the-bin (#163).
54
+ // close releases the parent's dup; the child holds its own.
55
+ function openDaemonLog(projectRoot, { maxBytes = MAX_LOG_BYTES, fs = realFs } = {}) {
56
+ let fd;
57
+ try {
58
+ // The parent must create .session/: only startDaemon creates it today, and
59
+ // that runs in the child — too late for an fd the parent has to open first.
60
+ fs.mkdirSync(sessionDir(projectRoot), { recursive: true });
61
+
62
+ const logPath = logFilePath(projectRoot);
63
+ rotateIfLarge(logPath, maxBytes, fs);
64
+
65
+ // Append, never 'w'. Lock-race losers exit ELOCKED through
66
+ // bin/mauto-session-daemon.js and write to this same file; truncating on
67
+ // open would let a loser erase the winner's just-written crash trace.
68
+ fd = fs.openSync(logPath, 'a');
69
+ } catch (_) {
70
+ return IGNORED;
71
+ }
72
+
73
+ const write = (text) => {
74
+ try {
75
+ fs.writeSync(fd, text);
76
+ } catch (_) {
77
+ // Full disk, closed fd — the diagnostics are optional, the spawn is not.
78
+ }
79
+ };
80
+
81
+ let closed = false;
82
+ const close = () => {
83
+ if (closed) return;
84
+ closed = true;
85
+ try {
86
+ fs.closeSync(fd);
87
+ } catch (_) {
88
+ // Already reaped by the runtime; nothing left to do.
89
+ }
90
+ };
91
+
92
+ return { stdio: ['ignore', fd, fd], write, close };
93
+ }
94
+
95
+ module.exports = { MAX_LOG_BYTES, IGNORED, daemonLogHint, openDaemonLog };
@@ -6,6 +6,7 @@
6
6
  // mauto.sock — Unix domain socket the daemon listens on
7
7
  // daemon.pid — pidfile written once the daemon is listening
8
8
  // session.json — handle describing the live session (device pin, socket, pid)
9
+ // daemon.log — bounded stdout/stderr capture from the detached daemon
9
10
  //
10
11
  // These are intentionally side-effect-free so they can be unit-tested without
11
12
  // touching the filesystem.
@@ -19,6 +20,7 @@ const SOCKET_NAME = 'mauto.sock';
19
20
  const PID_NAME = 'daemon.pid';
20
21
  const HANDLE_NAME = 'session.json';
21
22
  const LOCK_NAME = 'daemon.lock';
23
+ const LOG_NAME = 'daemon.log';
22
24
 
23
25
  // Unix domain socket paths have a hard length limit (~104 bytes on macOS,
24
26
  // ~108 on Linux). Deep project roots blow past it, so when the in-workspace
@@ -50,6 +52,14 @@ function socketPath(projectRoot) {
50
52
  return path.join(os.tmpdir(), `mauto-${hash}.sock`);
51
53
  }
52
54
 
55
+ // Deliberately has no os.tmpdir() fallback, unlike socketPath(). That fallback
56
+ // exists solely because Unix domain socket paths hit a ~104-byte sockaddr
57
+ // limit; a regular file has no such limit, so the log stays in the workspace
58
+ // unconditionally and is always findable next to the session it describes.
59
+ function logFilePath(projectRoot) {
60
+ return path.join(sessionDir(projectRoot), LOG_NAME);
61
+ }
62
+
53
63
  function pidFilePath(projectRoot) {
54
64
  return path.join(sessionDir(projectRoot), PID_NAME);
55
65
  }
@@ -70,10 +80,12 @@ module.exports = {
70
80
  PID_NAME,
71
81
  HANDLE_NAME,
72
82
  LOCK_NAME,
83
+ LOG_NAME,
73
84
  SOCKET_PATH_LIMIT,
74
85
  sessionDir,
75
86
  workspaceSocketPath,
76
87
  socketPath,
88
+ logFilePath,
77
89
  pidFilePath,
78
90
  handlePath,
79
91
  lockPath,
@@ -1,13 +1,17 @@
1
1
  'use strict';
2
2
 
3
3
  // Spawns the device session daemon as a DETACHED background process and waits
4
- // until it is reachable. The spawn fn is injectable so the spawn args and the
5
- // readiness-poll contract are unit-testable without launching a real daemon.
4
+ // until it is reachable. The daemon's output is captured to the workspace log
5
+ // (mobile-automator/.session/daemon.log) instead of /dev/null, so a crashing
6
+ // engine leaves a readable trace behind. The spawn and openLog fns are
7
+ // injectable so the spawn args, the log wiring and the readiness-poll contract
8
+ // are all unit-testable without launching a real daemon or touching disk.
6
9
 
7
10
  const path = require('path');
8
11
  const childProcess = require('child_process');
9
12
 
10
13
  const sessionClient = require('./session-client');
14
+ const sessionLog = require('./session-log');
11
15
 
12
16
  const DAEMON_BIN = path.join(__dirname, '..', '..', 'bin', 'mauto-session-daemon.js');
13
17
  const DEFAULT_READY_TIMEOUT_MS = 15000;
@@ -26,6 +30,7 @@ async function spawnDaemon({
26
30
  readyTimeoutMs = DEFAULT_READY_TIMEOUT_MS,
27
31
  pollMs = DEFAULT_POLL_MS,
28
32
  isAlive = sessionClient.isAlive,
33
+ openLog = sessionLog.openDaemonLog,
29
34
  } = {}) {
30
35
  if (!projectRoot) throw new TypeError('spawnDaemon requires projectRoot');
31
36
 
@@ -33,35 +38,55 @@ async function spawnDaemon({
33
38
  if (device) env.MAUTO_SESSION_DEVICE = device;
34
39
  if (idleMs !== undefined && idleMs !== null) env.MAUTO_SESSION_IDLE_MS = String(idleMs);
35
40
 
36
- const child = spawn(process.execPath, [DAEMON_BIN], {
37
- detached: true,
38
- stdio: 'ignore',
39
- env,
40
- });
41
+ // Must precede the spawn: the fd has to exist before the child can inherit it.
42
+ // Never null — a workspace we cannot write to yields the `stdio: 'ignore'`
43
+ // handle, so there is nothing to branch on here. The daemon writes its own
44
+ // spawn banner (bin/mauto-session-daemon.js), which is why this file no
45
+ // longer needs the child's pid.
46
+ const log = openLog(projectRoot);
41
47
 
42
- // A spawn that fails synchronously (EMFILE, EACCES, ENOENT on the bin) emits
43
- // an 'error' event instead of throwing. Without a listener that error would
44
- // crash the one-shot verb process; with it we bail out of the readiness poll
45
- // immediately instead of waiting out the full 15s window for a child that can
46
- // never come up. No 'exit' short-circuit here: a loser exiting ELOCKED is
47
- // expected, and the winner may still come up within the poll window.
48
- let spawnError = null;
49
- if (child && typeof child.on === 'function') {
50
- child.on('error', (err) => {
51
- spawnError = err;
48
+ try {
49
+ const child = spawn(process.execPath, [DAEMON_BIN], {
50
+ detached: true,
51
+ stdio: log.stdio,
52
+ env,
52
53
  });
53
- }
54
54
 
55
- // Let the daemon outlive this one-shot verb process.
56
- if (child && typeof child.unref === 'function') child.unref();
55
+ // A spawn that fails to exec (EMFILE, EACCES, ENOENT on the bin) emits an
56
+ // 'error' event instead of throwing. Without a listener that error would
57
+ // crash the one-shot verb process; with it we bail out of the readiness
58
+ // poll immediately instead of waiting out the full 15s window for a child
59
+ // that can never come up. No 'exit' short-circuit here: a loser exiting
60
+ // ELOCKED is expected, and the winner may still come up within the window.
61
+ //
62
+ // The error is also WRITTEN to the log, not just used as a flag: a child
63
+ // that never execs has no stdout/stderr of its own, so the parent is the
64
+ // only witness. Dropping it here would rebuild the exact hole #163 fixes,
65
+ // one layer up.
66
+ let spawnError = null;
67
+ if (child && typeof child.on === 'function') {
68
+ child.on('error', (err) => {
69
+ spawnError = err;
70
+ log.write(`mauto: daemon spawn failed: ${err && err.stack ? err.stack : err}\n`);
71
+ });
72
+ }
73
+
74
+ // Let the daemon outlive this one-shot verb process.
75
+ if (child && typeof child.unref === 'function') child.unref();
57
76
 
58
- const deadline = Date.now() + readyTimeoutMs;
59
- while (Date.now() < deadline) {
60
- if (spawnError) return false;
61
- if (await isAlive(projectRoot)) return true;
62
- await sleep(pollMs);
77
+ const deadline = Date.now() + readyTimeoutMs;
78
+ while (Date.now() < deadline) {
79
+ if (spawnError) return false;
80
+ if (await isAlive(projectRoot)) return true;
81
+ await sleep(pollMs);
82
+ }
83
+ return false;
84
+ } finally {
85
+ // The child holds its own dup, so the parent releases its descriptor rather
86
+ // than leaking one per verb invocation. In `finally` because the 'error'
87
+ // listener above fires asynchronously — the handle has to outlive the poll.
88
+ log.close();
63
89
  }
64
- return false;
65
90
  }
66
91
 
67
92
  module.exports = { spawnDaemon, DAEMON_BIN };