verikun 0.26.3 → 0.27.1

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.
@@ -37,15 +37,20 @@ examples below use `vk`.
37
37
  ## Before you start (once per session)
38
38
 
39
39
  Run `vk doctor` once before the first device command. Alongside the adb/device checks it
40
- reports version staleness on stderr — it never fails on that, so treat it as information:
40
+ reports version staleness and host problems on stderr — it never fails on those, so treat
41
+ them as information:
41
42
 
42
43
  - **`claude-code-plugin: … behind this CLI`** → **this skill file is out of date.** Trust
43
44
  `vk --help` over what you read here when they disagree, and tell the user to run
44
45
  `claude plugin update verikun@verikun` and restart Claude Code.
45
46
  - **`verikun: … npm has <newer>`** → tell the user `npm install -g verikun@latest`.
47
+ - **`adb server: … leaking USB handles`** → **the host's adb server has rotted.** Devices
48
+ will drop mid-flow for reasons that look like your selectors failing. Tell the user to run
49
+ `adb kill-server && adb start-server` — safe, and devices reconnect in seconds.
46
50
 
47
51
  **Tell the user, don't run it.** Upgrading changes their machine, and the plugin path needs
48
- a Claude Code restart to take effect. Mention it once and move on.
52
+ a Claude Code restart to take effect; restarting adb is host-wide and drops every device on
53
+ the machine, not just yours. Mention it once and move on.
49
54
 
50
55
  ## The loop: act → inspect → assert
51
56
 
@@ -608,7 +613,7 @@ owns the redaction and the review-first flow.
608
613
  ## Gotchas
609
614
 
610
615
  - **Prepare the device once** for reliable dumps: `vk device prep` (a physical device
611
- needs `--device <serial>`). Live animations can make `vk ui` flaky (it already retries 3×).
616
+ needs `--device <serial>`). Live animations can make `vk ui` flaky.
612
617
  - **A slept device returns the LOCK SCREEN, not an error.** The dump succeeds and hands
613
618
  back `com.android.systemui` — so selectors miss for a reason unrelated to the app.
614
619
  verikun detects this, wakes the device and clears a *swipe* lock automatically; on a
package/CHANGELOG.md CHANGED
@@ -6,6 +6,31 @@ All notable changes to this project are documented here. The format is based on
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.27.1] - 2026-09-14
10
+
11
+ A hierarchy read the device killed is now waited out instead of ending the test.
12
+
13
+ ### Fixed
14
+ - **Hierarchy reads** ride out a dump the device killed, for the caller's full wait budget,
15
+ instead of aborting after three fast retries. ([#137])
16
+ - **`assert --gone` / `wait --gone`** no longer count a killed read as an absence; a window of
17
+ only killed reads exits `3` instead of reporting a miss. ([#137])
18
+
19
+ ### Changed
20
+ - **A killed dump** names memory pressure and is never failed over or retired — the device is
21
+ busy, not broken. ([#137])
22
+
23
+ [#137]: https://github.com/ddikman/verikun/issues/137
24
+
25
+ ## [0.27.0] - 2026-09-13
26
+
27
+ ### Added
28
+ - **`vk doctor`** warns when the host's adb server has leaked USB handles, naming the restart that fixes it.
29
+ - **`vk server`** restarts a rotted adb server while idle; `VERIKUN_NO_ADB_RECYCLE=1` opts out.
30
+
31
+ ### Changed
32
+ - **Docs**: every page trimmed to what a reader needs; history and design rationale removed, gotchas kept, open issues linked.
33
+
9
34
  ## [0.26.3] - 2026-09-11
10
35
 
11
36
  A `vk ai` test's description is no longer compiled into steps of its own.
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
  - **Puppeteer for native mobile** — a thin wrapper over native Android and iOS automation runners with zero runtime dependencies.
9
9
  - **Natural-language tests** — `vk ai <file>`: runs plain-English tests, compiled once and replayed model-free (~$0), calling a model only to self-heal a drifted step. Tests share a preamble with `@include`, written once instead of pasted into each. A compile that does not cover its test is rejected rather than cached as a pass. [What that costs](https://ddikman.github.io/verikun/reference/cost/), and how the `--max-cost-usd` ceiling bounds it.
10
10
  - **Self-improving** — the agent runner will provide prescriptive improvements to existing scripts to help stabilise flakiness for future runs.
11
- - **CI-ready** — `vk suite` runs a folder of tests as one gated pass/fail run, across one device or a whole pool of them; `vk server` exposes real devices over an authenticated tunnel so a disposable CI runner (no phone attached) can still drive them, and routes around any that goes bad.
11
+ - **CI-ready** — `vk suite` runs a folder of tests as one gated pass/fail run, across one device or a whole pool of them; `vk server` exposes real devices over an authenticated tunnel so a disposable CI runner (no phone attached) can still drive them, and routes around any that goes bad — including restarting a host adb server that has started dropping them.
12
12
 
13
13
  ```
14
14
  $ vk ui
@@ -0,0 +1,208 @@
1
+ "use strict";
2
+ /**
3
+ * Is the HOST's adb server healthy, or has it rotted?
4
+ *
5
+ * A long-lived adb server leaks IOKit Mach ports and never recovers. Measured on macOS
6
+ * 2026-09-12: a 9-day-old server logging EXC_GUARD `GUARD_TYPE_MACH_PORT` / `INVALID_NAME`
7
+ * at 2/sec — 27,983 in 7h, every one of them from the adb pid — having burned 92 minutes
8
+ * of CPU on the error loop. `adb kill-server && adb start-server` took it to zero and every
9
+ * device came back in ~5s.
10
+ *
11
+ * WHY VERIKUN CARES, rather than leaving this to the operator: adb rot DEFEATS DEVICE
12
+ * FAILOVER. `device/failover.ts` moves the server off a device that fails, but every
13
+ * candidate sits behind the same host adb — so when the transport is what broke, failover
14
+ * walks the pool retiring healthy phones for a host-side fault. That is the same polarity
15
+ * error `ARTIFACT_RULES` exists to prevent on the install path: enumerate the thing you can
16
+ * actually attribute, and never blame the open-ended side.
17
+ *
18
+ * THE RATE IS A LEAKED-HANDLE COUNTER, which is what makes this measurable rather than
19
+ * guessed. adb scans USB at ~1Hz and each STALE device handle throws one violation per
20
+ * pass, so the per-minute count reads directly as "how many handles adb has leaked". It
21
+ * stepped 59 -> 118/min at the exact second a device re-enumerated, and never came back
22
+ * down. A healthy server sits at exactly 0.
23
+ *
24
+ * EVIDENCE, NEVER AGE, is the trigger — see `adbServerRotting`. Age alone would restart a
25
+ * perfectly good server, which for a default-on behaviour is a new way to fail; the whole
26
+ * point of a measured signal is that a healthy host is never disturbed.
27
+ *
28
+ * macOS-only, and that is honest rather than lazy: EXC_GUARD and Mach ports do not exist on
29
+ * Linux, and the rot has not been measured there. Everywhere else this reports `undefined`
30
+ * and every caller degrades to doing nothing — never to a blind restart.
31
+ */
32
+ Object.defineProperty(exports, "__esModule", { value: true });
33
+ exports.VIOLATION_WINDOW_MS = void 0;
34
+ exports.parseEtime = parseEtime;
35
+ exports.countViolations = countViolations;
36
+ exports.adbServerRotting = adbServerRotting;
37
+ exports.describeRot = describeRot;
38
+ exports.adbRecycleEnabled = adbRecycleEnabled;
39
+ exports.adbServerHealth = adbServerHealth;
40
+ exports.adbHealthProbe = adbHealthProbe;
41
+ exports.recycleAdbServer = recycleAdbServer;
42
+ const node_os_1 = require("node:os");
43
+ const exec_1 = require("./exec");
44
+ /** The window we count violations over. Long enough that a ~1Hz loop is unmissable, short
45
+ * enough that `log show` stays ~1s (measured: 1.2s for 2m). */
46
+ exports.VIOLATION_WINDOW_MS = 2 * 60 * 1000;
47
+ /**
48
+ * `log`'s own absolute path, not a bare `log`. Belt-and-braces against a shadowing shell
49
+ * function (the author hit exactly that while investigating: `log` was a zsh function and
50
+ * every query failed with "too many arguments"). We spawn without a shell so this cannot
51
+ * bite us today, but the absolute path costs nothing and documents the hazard.
52
+ */
53
+ const LOG_BIN = '/usr/bin/log';
54
+ /**
55
+ * Match the kernel's report of adb's guard violations, and NOTHING else.
56
+ *
57
+ * All three clauses are load-bearing. Without `process == "kernel"` this matches unrelated
58
+ * processes; without the `[adb:` clause it matches any process's guard violations, and a
59
+ * host with an unrelated misbehaving binary would be told its adb had rotted. The line the
60
+ * kernel actually emits:
61
+ *
62
+ * ERROR: [adb:96399] EXC_GUARD AST: type=0x1 flavor=0x200 target=0x24d7 ...
63
+ */
64
+ const VIOLATION_PREDICATE = 'process == "kernel" AND eventMessage CONTAINS "EXC_GUARD" AND eventMessage CONTAINS "[adb:"';
65
+ /**
66
+ * `ps -o etime=` elapsed time -> ms. Formats, narrowest first: `MM:SS`, `HH:MM:SS`,
67
+ * `DD-HH:MM:SS`. Returns undefined rather than throwing on anything unrecognised — this
68
+ * feeds an advisory, and a parse failure must degrade to silence, never to a wrong number.
69
+ */
70
+ function parseEtime(raw) {
71
+ const s = raw.trim();
72
+ if (!s)
73
+ return undefined;
74
+ const [days, clock] = s.includes('-') ? s.split('-', 2) : ['0', s];
75
+ const parts = clock.split(':');
76
+ if (parts.length < 2 || parts.length > 3)
77
+ return undefined;
78
+ // Test the DIGITS, not Number(): `Number('')` is 0, not NaN, so a leading '-' ("-5:00")
79
+ // would otherwise parse as a happy five minutes instead of being rejected.
80
+ const fields = [days, ...parts];
81
+ if (!fields.every((f) => /^\d+$/.test(f)))
82
+ return undefined;
83
+ const nums = fields.map((f) => Number(f));
84
+ const [d, ...rest] = nums;
85
+ const [h, m, sec] = rest.length === 3 ? rest : [0, ...rest];
86
+ return ((d * 24 + h) * 60 * 60 + m * 60 + sec) * 1000;
87
+ }
88
+ /**
89
+ * Count real events in `log show --style compact` output.
90
+ *
91
+ * It ALWAYS prints a `Timestamp Ty Process[PID:TID]` header, so a naive line count reports
92
+ * 1 on a perfectly healthy host — which would make every caller cry rot forever. Counting
93
+ * only lines that name adb is both the fix and a second guard on the predicate.
94
+ */
95
+ function countViolations(stdout) {
96
+ return stdout.split('\n').filter((l) => l.includes('[adb:')).length;
97
+ }
98
+ /**
99
+ * Has this adb server rotted? Evidence only.
100
+ *
101
+ * A healthy server produces EXACTLY zero over the window (measured repeatedly on a freshly
102
+ * restarted one), and a rotted one produces 60-120 per minute — there is no middle ground
103
+ * to tune a threshold against, so any nonzero count is the signal. `undefined` violations
104
+ * (not macOS, or the query failed) is NEVER rot: we do not restart on a hunch.
105
+ */
106
+ function adbServerRotting(health) {
107
+ return (health.violations ?? 0) > 0;
108
+ }
109
+ /** One line a human can act on, or undefined when there is nothing worth saying. */
110
+ function describeRot(health) {
111
+ if (!adbServerRotting(health))
112
+ return undefined;
113
+ const perMin = Math.round((health.violations / (health.windowMs ?? exports.VIOLATION_WINDOW_MS)) * 60_000);
114
+ const age = health.ageMs !== undefined ? `, up ${Math.floor(health.ageMs / 3_600_000)}h` : '';
115
+ return `adb server is leaking USB handles (~${perMin} kernel guard violations/min${age}) — devices will drop`;
116
+ }
117
+ /**
118
+ * Is the idle adb-server recycle active? ONE definition, because two callers ask — the
119
+ * server's timer and its startup banner — and a banner that disagrees with the behaviour is
120
+ * worse than no banner at all.
121
+ *
122
+ * Android only: `adb` is the only transport that rots this way, so an iOS server never pays
123
+ * for the check. On by default (`vk server` is built to sit on a CI host for days, which is
124
+ * the condition that rots it); `VERIKUN_NO_ADB_RECYCLE=1` restores the previous behaviour
125
+ * exactly, for the rare host running other adb work alongside the server.
126
+ */
127
+ function adbRecycleEnabled(platform) {
128
+ return platform === 'android' && process.env.VERIKUN_NO_ADB_RECYCLE !== '1';
129
+ }
130
+ /** The running adb server's pid. Undefined when there is none, or when `pgrep` is absent. */
131
+ function adbServerPid() {
132
+ try {
133
+ // `adb -L tcp:5037 fork-server server` is the canonical argv; match loosely so a
134
+ // non-default port or an ADB_SERVER_SOCKET still resolves.
135
+ const r = (0, exec_1.runText)('pgrep', ['-f', 'adb.*fork-server'], { timeout: 5000 });
136
+ const pid = Number(r.stdout.split('\n')[0]?.trim());
137
+ return Number.isInteger(pid) && pid > 0 ? pid : undefined;
138
+ }
139
+ catch {
140
+ return undefined;
141
+ }
142
+ }
143
+ /** How long pid has been running, via `ps -o etime=`. */
144
+ function processAgeMs(pid) {
145
+ try {
146
+ const r = (0, exec_1.runText)('ps', ['-p', String(pid), '-o', 'etime='], { timeout: 5000 });
147
+ return parseEtime(r.stdout);
148
+ }
149
+ catch {
150
+ return undefined;
151
+ }
152
+ }
153
+ /**
154
+ * Survey the host's adb server. Never throws: every probe is wrapped, and an unavailable
155
+ * signal comes back `undefined` so callers do nothing rather than something wrong.
156
+ */
157
+ function adbServerHealth(windowMs = exports.VIOLATION_WINDOW_MS) {
158
+ const pid = adbServerPid();
159
+ const health = { pid, ageMs: pid ? processAgeMs(pid) : undefined };
160
+ if ((0, node_os_1.platform)() !== 'darwin' || !pid)
161
+ return health;
162
+ try {
163
+ const secs = Math.max(1, Math.round(windowMs / 1000));
164
+ const r = (0, exec_1.runText)(LOG_BIN, ['show', '--last', `${secs}s`, '--style', 'compact', '--predicate', VIOLATION_PREDICATE], {
165
+ timeout: 20000,
166
+ });
167
+ if (r.code !== 0)
168
+ return health;
169
+ return { ...health, violations: countViolations(r.stdout), windowMs };
170
+ }
171
+ catch {
172
+ return health; // log(1) missing, sandboxed, or slow — no opinion, not a failure
173
+ }
174
+ }
175
+ /**
176
+ * The `vk doctor` line. ADVISORY, always: a rotted adb server is a thing to fix, not a
177
+ * machine that cannot drive a device, and exit 3 is reserved for the latter. Returns null
178
+ * when there is nothing to say, so doctor stays quiet on a healthy host.
179
+ */
180
+ function adbHealthProbe(health = adbServerHealth()) {
181
+ const detail = describeRot(health);
182
+ if (!detail)
183
+ return null;
184
+ return {
185
+ name: 'adb server',
186
+ ok: true,
187
+ advisory: true,
188
+ detail,
189
+ hint: 'restart it: `adb kill-server && adb start-server` (safe; devices reconnect in a few seconds)',
190
+ };
191
+ }
192
+ /**
193
+ * Restart the host's adb server. Returns whether it came back.
194
+ *
195
+ * HOST-GLOBAL and therefore never called speculatively — `kill-server` drops every
196
+ * transport on the machine, including devices this process does not own. Callers must have
197
+ * evidence (`adbServerRotting`) and, on the server, an idle pool.
198
+ */
199
+ function recycleAdbServer(adb) {
200
+ try {
201
+ (0, exec_1.runText)(adb, ['kill-server'], { timeout: 20000 });
202
+ const r = (0, exec_1.runText)(adb, ['start-server'], { timeout: 30000 });
203
+ return r.code === 0;
204
+ }
205
+ catch {
206
+ return false; // never a new way to fail: the caller logs and carries on
207
+ }
208
+ }
@@ -89,8 +89,10 @@ exports.DEFAULT_GUARD_SETTLE_MS = 1500;
89
89
  /** Re-dump cadence inside a guard's settle window. */
90
90
  const GUARD_POLL_MS = 150;
91
91
  /**
92
- * How long a guard keeps looking at a screen it cannot read at all, when the reason is
93
- * NoWindowError the app was force-stopped or is mid-launch and has genuinely not drawn.
92
+ * How long a guard keeps looking at a screen it cannot read at all, when the reason is one
93
+ * that CLEARS ON ITS OWN (`TransientReadError`): the app force-stopped or mid-launch and not
94
+ * yet drawn (`NoWindowError`), or the dumper SIGKILLed under memory pressure
95
+ * (`DumpKilledError`, issue #137).
94
96
  *
95
97
  * Separate from `settleMs`, which answers "how long before I believe this selector is
96
98
  * absent?". This answers "how long before I believe there is no screen to ask?" — a
@@ -113,7 +115,7 @@ const GUARD_POLL_MS = 150;
113
115
  * Not configurable on purpose: a dial here is one more thing to explain, and every value a
114
116
  * user might pick is worse than the measurement.
115
117
  */
116
- const NO_WINDOW_GRACE_MS = 10_000;
118
+ const TRANSIENT_READ_GRACE_MS = 10_000;
117
119
  /** Consecutive identical screen snapshots before a loop is believed to be stuck.
118
120
  *
119
121
  * This check is a TIME SAVER and nothing more. A loop already fails when its exit
@@ -205,8 +207,9 @@ async function runPlan(plan, deps) {
205
207
  * probe a loop-exit check needs.
206
208
  * So one dump attempt always happens regardless of the window.
207
209
  *
208
- * A THIRD clock sits beside both: a screen that cannot be read because the app has not
209
- * drawn (NoWindowError) is retried against NO_WINDOW_GRACE_MS, not against `settleMs`.
210
+ * A THIRD clock sits beside both: a screen that is not READABLE AT ALL for a reason that
211
+ * clears on its own (a TransientReadError) is retried against TRANSIENT_READ_GRACE_MS,
212
+ * not against `settleMs`.
210
213
  * That is deliberately independent — "is this selector absent?" and "is there a screen to
211
214
  * ask at all?" are different questions, and only the second one aborts the run.
212
215
  *
@@ -229,10 +232,10 @@ async function runPlan(plan, deps) {
229
232
  if (deps.platform)
230
233
  (0, state_support_1.assertStateSupported)(sel, deps.platform);
231
234
  const deadline = Date.now() + Math.max(0, settleMs);
232
- // A screen that cannot be read AT ALL gets its own, longer clock — see NO_WINDOW_GRACE_MS.
235
+ // A screen that cannot be read AT ALL gets its own, longer clock — see TRANSIENT_READ_GRACE_MS.
233
236
  // Clamped by the run deadline so a guard can never push a run past --timeout: the grace
234
237
  // exists to spend budget the caller already has, never to invent more.
235
- const noWindowDeadline = Math.min(Date.now() + NO_WINDOW_GRACE_MS, deps.deadline ?? Infinity);
238
+ const transientDeadline = Math.min(Date.now() + TRANSIENT_READ_GRACE_MS, deps.deadline ?? Infinity);
236
239
  // A non-zero window must buy at least one SECOND look, independent of the clock.
237
240
  // Measured on emulator-5554: one uiautomator dump costs ~2.4s, which already exceeds
238
241
  // a 1.5s window — so a purely time-boxed loop returns after a single dump and the
@@ -280,7 +283,7 @@ async function runPlan(plan, deps) {
280
283
  // One successful read — even an empty tree — and the ordinary semantics resume exactly:
281
284
  // settleMs=0 is still a single-shot probe. It must never make a merely ABSENT selector
282
285
  // more patient, or every guard silently costs 10s.
283
- if (!everRead && lastErr instanceof errors_1.NoWindowError && Date.now() < noWindowDeadline) {
286
+ if (!everRead && lastErr instanceof errors_1.TransientReadError && Date.now() < transientDeadline) {
284
287
  await (0, wait_1.sleep)(GUARD_POLL_MS);
285
288
  continue;
286
289
  }
package/dist/cli.js CHANGED
@@ -93,6 +93,7 @@ const suite_1 = require("./suite");
93
93
  const failover_1 = require("./device/failover");
94
94
  const wait_1 = require("./wait");
95
95
  const version_1 = require("./version");
96
+ const adb_health_1 = require("./adb-health");
96
97
  const update_check_1 = require("./update-check");
97
98
  const auto_wait_1 = require("./commands/auto-wait");
98
99
  const batch_1 = require("./commands/batch");
@@ -475,6 +476,12 @@ async function cmdDoctor(ctx) {
475
476
  const adb = process.env.ADB || 'adb';
476
477
  if (!reportProbe((0, drivers_1.probeAdb)()))
477
478
  return 3;
479
+ // adb being PRESENT is not the same as adb being WELL: a long-lived server leaks USB
480
+ // handles and starts dropping devices mid-run, and no device-level remedy reaches it
481
+ // (see adb-health.ts). Advisory and evidence-only, so a healthy host stays silent.
482
+ const adbRot = (0, adb_health_1.adbHealthProbe)();
483
+ if (adbRot)
484
+ reportProbe(adbRot);
478
485
  const devices = ctx.driver.listDevices();
479
486
  const usable = devices.filter((d) => d.state === 'device');
480
487
  const claims = (0, claims_1.claimsEnabled)();
@@ -910,10 +917,13 @@ async function cmdWait(ctx) {
910
917
  const timeout = (0, args_1.flagNum)(ctx.flags, 'timeout') ?? 10000;
911
918
  const interval = (0, args_1.flagNum)(ctx.flags, 'interval') ?? 400;
912
919
  const deadline = Date.now() + timeout;
913
- const barrier = new auto_wait_1.BarrierTally(ctx);
920
+ const barrier = new auto_wait_1.ReadTally(ctx);
914
921
  while (Date.now() < deadline) {
915
- const { matches, tier } = (0, selector_1.matchElements)(barrier.note((0, auto_wait_1.readForPoll)(ctx)), sel);
916
- if (gone ? matches.length === 0 : matches.length > 0) {
922
+ const { matches, tier } = (0, selector_1.matchElements)((0, auto_wait_1.readForPoll)(ctx, barrier), sel);
923
+ // A read that did not happen proves nothing, and `--gone` is satisfied by an EMPTY one —
924
+ // so without this a kill storm answers "gone" on its first poll, exit 0. Absence has to be
925
+ // observed. (Measured on a Pixel 3a while fixing #137.)
926
+ if (!barrier.lastWasBlind() && (gone ? matches.length === 0 : matches.length > 0)) {
917
927
  ctx.record?.note({ selector: sel, tier, element: matches[0], message: gone ? 'gone' : `${matches.length} match(es)` });
918
928
  if (gone)
919
929
  (0, output_1.out)(`gone: '${sel.raw}'`);
@@ -923,6 +933,11 @@ async function cmdWait(ctx) {
923
933
  }
924
934
  await (0, wait_1.sleep)(interval);
925
935
  }
936
+ // A window that never once READ the screen has no timeout to report — it has an environment
937
+ // failure, and it throws (issue #137) rather than reaching the `return 1` below. That is also
938
+ // what a killed dump did before this fix, so the recorded step keeps its `error` shape
939
+ // instead of turning into a `failure`.
940
+ barrier.rethrowIfBlind();
926
941
  // A barrier can only explain a miss: with --gone the element is absent and the wait passed above.
927
942
  const why = withStop(gone ? '' : barrier.clause());
928
943
  ctx.record?.note({ selector: sel, message: `timeout after ${timeout}ms${gone ? ' (still present)' : ' (never appeared)'}${why}` });
@@ -972,12 +987,23 @@ async function cmdAssert(ctx) {
972
987
  // Auto-wait subsumes the common "wait then assert": poll until the assertion
973
988
  // passes or the window elapses. `--gone` therefore waits for disappearance.
974
989
  const deadline = Date.now() + (0, auto_wait_1.waitWindowMs)(ctx.flags);
975
- const barrier = new auto_wait_1.BarrierTally(ctx);
976
- let result = evalAssert(barrier.note((0, auto_wait_1.readForPoll)(ctx)), sel, ctx.flags);
990
+ const barrier = new auto_wait_1.ReadTally(ctx);
991
+ // A verdict is only worth banking if the read behind it happened. `--gone` (and `--count 0`)
992
+ // pass on an EMPTY tree, which is exactly what an absorbed transient hands back — so a single
993
+ // killed dump mid-window could otherwise bank a green the screen never showed. Poll again
994
+ // instead; `rethrowIfBlind()` below handles a window that stayed blind to the end.
995
+ const look = () => {
996
+ const result = evalAssert((0, auto_wait_1.readForPoll)(ctx, barrier), sel, ctx.flags);
997
+ return barrier.lastWasBlind() ? { ...result, pass: false } : result;
998
+ };
999
+ let result = look();
977
1000
  while (!result.pass && Date.now() < deadline) {
978
1001
  await (0, wait_1.sleep)((0, auto_wait_1.pollStep)(ctx.flags, deadline));
979
- result = evalAssert(barrier.note((0, auto_wait_1.readForPoll)(ctx)), sel, ctx.flags);
1002
+ result = look();
980
1003
  }
1004
+ // `--gone` PASSES on an empty read, so a window of nothing but killed dumps would report a
1005
+ // green earned from a screen nobody could read. Checked before `pass` is consumed (issue #137).
1006
+ barrier.rethrowIfBlind();
981
1007
  const { pass, matches } = result;
982
1008
  // Only a "not found" can be explained by a barrier: `--gone` passed if the tree was
983
1009
  // barrier-only, and a text mismatch found the element.
@@ -10,7 +10,7 @@
10
10
  // waiting lives here. The rules a new selector-resolving command must follow are in
11
11
  // CLAUDE.md, "Selector auto-wait".
12
12
  Object.defineProperty(exports, "__esModule", { value: true });
13
- exports.BarrierTally = void 0;
13
+ exports.ReadTally = void 0;
14
14
  exports.parseDuration = parseDuration;
15
15
  exports.waitWindowMs = waitWindowMs;
16
16
  exports.waitNote = waitNote;
@@ -53,48 +53,63 @@ function pollStep(flags, deadline) {
53
53
  return Math.min(interval, Math.max(0, deadline - Date.now()));
54
54
  }
55
55
  /**
56
- * Read the hierarchy for a caller that is polling, treating "no window yet" as "nothing on
57
- * screen yet" rather than a fatal environment error.
56
+ * Read the hierarchy for a caller that is polling, treating a read that will clear on its own
57
+ * as "nothing on screen yet" rather than as a fatal environment error.
58
58
  *
59
- * A `NoWindowError` means the device genuinely had nothing to show — `launch --clear` and
60
- * `launch` both leave a gap where the app has been stopped and has not drawn. That clears in
61
- * a second or two, so a caller that has a wait budget should keep polling; escalating to
59
+ * A `TransientReadError` means the device could not answer *right now* — `launch --clear` and
60
+ * `launch` both leave a gap where the app has been stopped and has not drawn (`NoWindowError`),
61
+ * and a memory-pressured phone SIGKILLs the dumper outright (`DumpKilledError`, issue #137).
62
+ * Both clear in seconds, so a caller that has a wait budget should keep polling; escalating to
62
63
  * exit 3 throws away the budget it was explicitly given. MEASURED: a `wait --timeout 120000`
63
- * used to abort at ~20s with 100 seconds unspent.
64
+ * used to abort at ~20s with 100 seconds unspent, and a `wait --timeout 30000` at 2.5s.
64
65
  *
65
66
  * Every OTHER capture failure still propagates untouched — a missing adb, an unauthorised
66
67
  * device or a wedged dumper is a machine to fix, and polling it for two minutes helps nobody.
68
+ *
69
+ * Pass the tally so the window can tell "the screen said nothing was there" from "nobody ever
70
+ * read the screen"; see `ReadTally.rethrowIfBlind`.
67
71
  */
68
- function readForPoll(ctx, opts = {}) {
72
+ function readForPoll(ctx, tally, opts = {}) {
69
73
  try {
70
- return ctx.driver.getElements(opts);
74
+ const els = ctx.driver.getElements(opts);
75
+ return tally ? tally.note(els) : els;
71
76
  }
72
77
  catch (e) {
73
- if (e instanceof errors_1.NoWindowError)
78
+ if (e instanceof errors_1.TransientReadError) {
79
+ tally?.noteBlind(e);
74
80
  return [];
81
+ }
75
82
  throw e;
76
83
  }
77
84
  }
78
85
  /**
79
- * Keeps track of whether the snapshots a poll loop read were barrier-only trees (see
80
- * ui/barrier.ts), so the failure at the end can say so instead of "never appeared".
86
+ * What every read in ONE poll window saw. This is the only layer that sees all of them, so
87
+ * both of the things a miss message needs to be honest about live here.
88
+ *
89
+ * **Was the tree barrier-only?** (issue #131) A sheet's barrier that outlives the whole wait
90
+ * made the step report "never appeared", sending the reader looking for a missing identifier
91
+ * in app code. Naming the barrier is the cheap half of that fix.
81
92
  *
82
- * A sheet's barrier that outlives the whole wait is the report behind issue #131: the
83
- * step waited 30s on a painted sheet and the message sent the reader looking for a missing
84
- * identifier in app code. Naming the barrier is the cheap half of that fix, and it belongs
85
- * to whoever owns the wait — this is the only layer that saw every read.
93
+ * **Did anyone ever read the screen at all?** (issue #137) An absorbed `DumpKilledError` costs
94
+ * a read and yields no elements, and a window made entirely of those has no grounds to call
95
+ * anything absent see `rethrowIfBlind`.
86
96
  */
87
- class BarrierTally {
97
+ class ReadTally {
88
98
  ctx;
89
99
  reads = 0;
100
+ okReads = 0;
90
101
  barrierReads = 0;
91
102
  last = null;
103
+ blind;
104
+ lastBlind = false;
92
105
  constructor(ctx) {
93
106
  this.ctx = ctx;
94
107
  }
95
108
  /** Record one snapshot. Returns it, so it can wrap a read in place. */
96
109
  note(els) {
97
110
  this.reads++;
111
+ this.okReads++;
112
+ this.lastBlind = false;
98
113
  this.last = (0, barrier_1.modalBarrierOnly)(els, this.ctx.driver.viewport());
99
114
  if (this.last)
100
115
  this.barrierReads++;
@@ -106,8 +121,47 @@ class BarrierTally {
106
121
  return '';
107
122
  return (0, barrier_1.barrierClause)(this.last, this.barrierReads === this.reads);
108
123
  }
124
+ /** Record a read that never happened — a transient failure `readForPoll` absorbed as `[]`. */
125
+ noteBlind(e) {
126
+ this.reads++;
127
+ this.blind = e;
128
+ this.lastBlind = true;
129
+ this.last = null; // a read that did not happen is not a barrier, and must not read as one
130
+ }
131
+ /**
132
+ * Did the most recent read fail to happen? Then it proves NOTHING, and least of all an
133
+ * absence — which `--gone` counts as a pass.
134
+ *
135
+ * Separate from `rethrowIfBlind`, and it has to be: that one asks about the whole window and
136
+ * fires at the deadline, but a `--gone` predicate is satisfied by the FIRST empty read and
137
+ * returns from inside the poll loop, so a window-level check never runs. MEASURED on a
138
+ * Pixel 3a while fixing #137: `wait --gone` under a kill storm exited 0 reporting "gone".
139
+ *
140
+ * Both `NoWindowError` and `DumpKilledError` count here. Unlike the deadline rule, the two
141
+ * need no asymmetry: an absorbed read yielded no elements to judge either way, so polling
142
+ * once more is right for both and costs a merely-absent selector nothing.
143
+ */
144
+ lastWasBlind() {
145
+ return this.lastBlind;
146
+ }
147
+ /**
148
+ * Refuse to report an absence this window never actually observed (issue #137).
149
+ *
150
+ * ONLY for a killed dump. The asymmetry is the point: a null root is the device ANSWERING
151
+ * "nothing is drawn", so "the selector is absent" is a true reading of it and
152
+ * `NoWindowError` keeps its existing behaviour exactly. A kill is no answer at all — and
153
+ * `assert --gone` turns "absent" into a PASS, so absorbing it silently would manufacture a
154
+ * green from a screen nobody could read.
155
+ *
156
+ * Gated on `okReads === 0`, the same `everRead` rule the engine's guard grace uses: one good
157
+ * read anywhere in the window means the screen was legible and an ordinary miss is honest.
158
+ */
159
+ rethrowIfBlind() {
160
+ if (this.okReads === 0 && this.blind instanceof errors_1.DumpKilledError)
161
+ throw this.blind;
162
+ }
109
163
  }
110
- exports.BarrierTally = BarrierTally;
164
+ exports.ReadTally = ReadTally;
111
165
  /**
112
166
  * matchElements with auto-wait: re-capture + re-match until at least one element
113
167
  * matches or the window elapses. Returns the final result either way (empty on miss),
@@ -115,11 +169,16 @@ exports.BarrierTally = BarrierTally;
115
169
  */
116
170
  async function matchWaiting(ctx, sel, opts = {}) {
117
171
  const deadline = Date.now() + waitWindowMs(ctx.flags);
118
- const barrier = new BarrierTally(ctx);
172
+ const barrier = new ReadTally(ctx);
119
173
  for (;;) {
120
- const res = (0, selector_1.matchElements)(barrier.note(readForPoll(ctx, opts)), sel);
121
- if (res.matches.length > 0 || Date.now() >= deadline)
174
+ const res = (0, selector_1.matchElements)(readForPoll(ctx, barrier, opts), sel);
175
+ if (res.matches.length > 0)
122
176
  return { ...res, barrier };
177
+ if (Date.now() >= deadline) {
178
+ // Before ANY caller can read this as an absence — `assert --gone` calls it a pass.
179
+ barrier.rethrowIfBlind();
180
+ return { ...res, barrier };
181
+ }
123
182
  await (0, wait_1.sleep)(pollStep(ctx.flags, deadline));
124
183
  }
125
184
  }
@@ -132,9 +191,9 @@ async function resolveOneWaiting(ctx, sel, opts = {}) {
132
191
  const windowMs = waitWindowMs(ctx.flags);
133
192
  const start = Date.now();
134
193
  const deadline = start + windowMs;
135
- const barrier = new BarrierTally(ctx);
194
+ const barrier = new ReadTally(ctx);
136
195
  for (;;) {
137
- const els = barrier.note(readForPoll(ctx, opts));
196
+ const els = readForPoll(ctx, barrier, opts);
138
197
  if ((0, selector_1.matchElements)(els, sel).matches.length >= 1) {
139
198
  const { element, tier } = (0, selector_1.resolveOne)(els, sel); // 1 → resolved; >1 → throws ambiguity
140
199
  // The snapshot rides along: scroll-into-view needs the scrollable containers
@@ -143,6 +202,7 @@ async function resolveOneWaiting(ctx, sel, opts = {}) {
143
202
  return { element, tier, waitedMs: Date.now() - start, elements: els };
144
203
  }
145
204
  if (Date.now() >= deadline) {
205
+ barrier.rethrowIfBlind();
146
206
  const waited = windowMs > 0 ? ` after ${(windowMs / 1000).toFixed(1)}s` : '';
147
207
  throw new errors_1.SelectorNotFoundError(`No element matched selector '${sel.raw}'${waited}.${barrier.clause()} Run \`verikun ui\` to inspect the current screen.`);
148
208
  }
@@ -84,16 +84,22 @@ function exitCodeOf(e) {
84
84
  return e instanceof errors_1.CliError ? e.exitCode : 3;
85
85
  }
86
86
  const messageOf = (e) => (e instanceof Error ? e.message : String(e ?? ''));
87
+ /** Why this device stays. Named per class so the operator reads the actual cause, not
88
+ * "transient" — the two want different responses (wait vs free some memory). */
89
+ const transientReason = (e) => e instanceof errors_1.DumpKilledError
90
+ ? 'the hierarchy dump was killed — the device is under memory pressure, not broken'
91
+ : 'the app has not drawn yet — this clears on its own';
87
92
  /**
88
93
  * The arms share everything except what an unrecognised exit-3 means, so they share
89
94
  * this and differ only in `fallback`.
90
95
  */
91
96
  function classify(e, fallback) {
92
- // Identity first, never message text: NoWindowError is exit 3 and its wording could
93
- // plausibly be matched by another rule, and getting this one wrong means rotating the
94
- // pool every time an app is mid-launch.
95
- if (e instanceof errors_1.NoWindowError) {
96
- return { move: false, kind: 'transient', reason: 'the app has not drawn yet — this clears on its own' };
97
+ // Identity first, never message text: both transient reads are exit 3 and their wording could
98
+ // plausibly be matched by another rule, and getting this one wrong means rotating the pool
99
+ // every time an app is mid-launch — or, for a killed dump, retiring a phone for being busy
100
+ // (issue #137).
101
+ if (e instanceof errors_1.TransientReadError) {
102
+ return { move: false, kind: 'transient', reason: transientReason(e) };
97
103
  }
98
104
  const code = exitCodeOf(e);
99
105
  if (code === 0 || code === 1)
@@ -140,7 +146,7 @@ function classifyInstallFailure(e) {
140
146
  const code = exitCodeOf(e);
141
147
  // Only an environment failure is ever the device's fault; a usage error (a rejected
142
148
  // extension, an unreadable path) is the caller's and no device fixes it.
143
- if (code === 3 && !(e instanceof errors_1.NoWindowError)) {
149
+ if (code === 3 && !(e instanceof errors_1.TransientReadError)) {
144
150
  const message = messageOf(e);
145
151
  // Same order as `classify` below, so the two arms can only ever differ in their
146
152
  // DEFAULT — which is the one difference between them that is meant to exist.
@@ -5,6 +5,7 @@ exports.probeAdb = probeAdb;
5
5
  exports.parseLockKind = parseLockKind;
6
6
  exports.looksLikeSystemUi = looksLikeSystemUi;
7
7
  exports.lockKindOf = lockKindOf;
8
+ exports.dumpWasKilled = dumpWasKilled;
8
9
  exports.escapeText = escapeText;
9
10
  exports.adbTransport = adbTransport;
10
11
  exports.severanceRisk = severanceRisk;
@@ -204,6 +205,26 @@ const BARRIER_SETTLE_MS = 300;
204
205
  * companion reports the same condition from getRootInActiveWindow(). Transient — see
205
206
  * NoWindowError. */
206
207
  const NULL_ROOT = /null root node/i;
208
+ /** 128 + SIGKILL — what a shell reports for a command the kernel killed outright. */
209
+ const EXIT_SIGKILL = 137;
210
+ /** The device shell's word for it, when the shell itself survived to say so. */
211
+ const KILLED_TEXT = /\bKilled\b/;
212
+ /**
213
+ * Was this read SIGKILLed rather than merely unsuccessful? (issue #137)
214
+ *
215
+ * THE EXIT CODE IS THE PRIMARY SIGNAL, not the text. MEASURED on a Pixel 3a: in the
216
+ * `adb shell '<cmd>'` form the device shell prints NOTHING when it is killed — both streams
217
+ * come back empty and only the status says 137. That is why the old message read
218
+ * "Failed to capture UI hierarchy after 3 attempts." with nothing after it. The word "Killed"
219
+ * does appear on some shells (issue #137 was reported with it), so it is kept as a second
220
+ * signal for an adb too old to propagate the remote status — but a matcher built on the text
221
+ * alone would have missed the very device this was reported from.
222
+ *
223
+ * Pure and exported for the unit suite only; nothing else imports it.
224
+ */
225
+ function dumpWasKilled(code, text) {
226
+ return code === EXIT_SIGKILL || KILLED_TEXT.test(text);
227
+ }
207
228
  /** Header sizes `screencap` writes before the pixels: width/height/format, plus a
208
229
  * colorspace word since Android 9. Newest first — see `screenshotRaw`. */
209
230
  const RAW_HEADER_SIZES = [16, 12];
@@ -898,6 +919,16 @@ class AdbDriver {
898
919
  if (NULL_ROOT.test(lastErr)) {
899
920
  throw new errors_1.NoWindowError();
900
921
  }
922
+ // A KILLED dump is the same deal, with one difference: attempt 0 has a real cure for
923
+ // one of its two causes (a companion holding the UiAutomation connection SIGKILLs a
924
+ // competing dump), so it still gets the remedy below and one more try. Once THAT is
925
+ // killed too, stop — a third back-to-back attempt is a third sample of the same instant
926
+ // (#137 measured all three losing), and the caller's poll interval is the spacing that
927
+ // actually helps. Judged on the DUMP's own result, never the combined text: a `cat` that
928
+ // reports a missing file is the kill's consequence, not evidence of one.
929
+ if (attempt > 0 && dumpWasKilled(dump.code, `${dump.stdout} ${dump.stderr}`)) {
930
+ throw new errors_1.DumpKilledError((0, errors_1.dumpKilledMessage)(lastErr));
931
+ }
901
932
  if (attempt === 0) {
902
933
  // A sleeping display is the other documented cause of a failed read. `ensureAwake` ran
903
934
  // before the dump, so reaching here means its answer went stale during a slow read (or
package/dist/errors.js CHANGED
@@ -6,7 +6,7 @@
6
6
  // 2 usage error, ambiguous selector, or a device another job is driving (caller must refine)
7
7
  // 3 environment error (adb/simctl missing, no usable device, dump failed)
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
- exports.AmbiguousSelectorError = exports.NO_WINDOW_MESSAGE = exports.NoWindowError = exports.SelectorNotFoundError = exports.probeFailure = exports.envError = exports.usageError = exports.CliError = void 0;
9
+ exports.AmbiguousSelectorError = exports.DUMP_KILLED_MESSAGE = exports.dumpKilledMessage = exports.DumpKilledError = exports.NO_WINDOW_MESSAGE = exports.NoWindowError = exports.TransientReadError = exports.SelectorNotFoundError = exports.probeFailure = exports.envError = exports.usageError = exports.CliError = void 0;
10
10
  exports.isEnvError = isEnvError;
11
11
  class CliError extends Error {
12
12
  exitCode;
@@ -51,6 +51,21 @@ class SelectorNotFoundError extends CliError {
51
51
  }
52
52
  }
53
53
  exports.SelectorNotFoundError = SelectorNotFoundError;
54
+ /**
55
+ * A hierarchy read that failed for a reason which CLEARS ON ITS OWN within seconds.
56
+ *
57
+ * The base exists so the three layers that mean "ride this out" — `readForPoll`, the engine's
58
+ * guard grace, and the failover classifier — say so once, by class, instead of listing
59
+ * subclasses they would each have to be remembered to update. Everything narrower than "this
60
+ * is transient" keeps checking the concrete class (the companion only stands down for a
61
+ * NoWindowError, not for a kill it had nothing to do with).
62
+ *
63
+ * Still exit 3, so a caller with NO budget is unaffected: an unabsorbed one exits exactly as
64
+ * it did before. What the class buys is the right to be polled through, not a softer exit.
65
+ */
66
+ class TransientReadError extends CliError {
67
+ }
68
+ exports.TransientReadError = TransientReadError;
54
69
  /**
55
70
  * There is no window to read right now — the app was just force-stopped, or is mid-launch
56
71
  * and has not drawn yet. `getRootInActiveWindow()` returns null and the platform says so.
@@ -66,7 +81,7 @@ exports.SelectorNotFoundError = SelectorNotFoundError;
66
81
  * and a `wait --timeout 120000` would abort at ~20s with 100 seconds of its budget unspent.
67
82
  * The retry belongs to the caller that knows how long it is willing to wait.
68
83
  */
69
- class NoWindowError extends CliError {
84
+ class NoWindowError extends TransientReadError {
70
85
  constructor(message = exports.NO_WINDOW_MESSAGE) {
71
86
  super(message, 3);
72
87
  this.name = 'NoWindowError';
@@ -86,6 +101,45 @@ exports.NoWindowError = NoWindowError;
86
101
  */
87
102
  exports.NO_WINDOW_MESSAGE = 'No window to read: the app has no drawn window right now — force-stopped, mid-launch, or ' +
88
103
  'its main thread is busy mid-transition. This normally clears within a few seconds.';
104
+ /**
105
+ * The dump process was SIGKILLed before it could answer (issue #137).
106
+ *
107
+ * NOT the same signal as NoWindowError, and the difference is the whole reason this is its own
108
+ * class. A null root is the device ANSWERING "nothing is drawn", so "the selector is absent" is
109
+ * a true reading of it. A kill is no answer at all, so calling the selector absent would be a
110
+ * fabrication — which is why a poll window that never once read the screen re-throws this
111
+ * instead of reporting a miss (`ReadTally.rethrowIfBlind`).
112
+ *
113
+ * MEASURED on a 4 GB-class phone (#137): the OS reaps the dumper while an app cold-starts, and
114
+ * the driver's three attempts fired back-to-back all landed inside the same second — so a
115
+ * `wait` holding a two-minute budget aborted at ~2.5s with 117 seconds unspent. Same rule as
116
+ * NoWindowError: the driver hands it up, the caller spends its own clock on it.
117
+ */
118
+ class DumpKilledError extends TransientReadError {
119
+ constructor(message = exports.DUMP_KILLED_MESSAGE) {
120
+ super(message, 3);
121
+ this.name = 'DumpKilledError';
122
+ }
123
+ }
124
+ exports.DumpKilledError = DumpKilledError;
125
+ /** The wording plus whatever the device actually said, for the one thrower that has evidence.
126
+ * Separate from the constructor so the wire codec can rebuild a message losslessly rather
127
+ * than re-prefixing one that already carries its detail. */
128
+ const dumpKilledMessage = (detail) => detail ? `${exports.DUMP_KILLED_MESSAGE} (${detail})` : exports.DUMP_KILLED_MESSAGE;
129
+ exports.dumpKilledMessage = dumpKilledMessage;
130
+ /**
131
+ * Names the CAUSE, because "the dump was killed" and "the device left adb" both used to arrive
132
+ * as `Failed to capture UI hierarchy after 3 attempts` and want opposite responses from whoever
133
+ * reads the report — wait vs go and find the phone.
134
+ *
135
+ * Both causes are named because both are real and the fix for each is different: memory pressure
136
+ * (wait, or test on a device with more headroom) and a competing UiAutomation client (stop it).
137
+ * verikun's own companion is the second one, and the driver already tries to clear that itself
138
+ * before this is ever thrown.
139
+ */
140
+ exports.DUMP_KILLED_MESSAGE = 'The UI hierarchy dump was killed before it could answer — the device reclaiming memory ' +
141
+ 'while an app starts, or another tool holding the one UiAutomation connection. This ' +
142
+ 'normally clears within seconds.';
89
143
  /** Selector matched >1 element. Exit 2. Carries the candidates so the agent runner
90
144
  * can ask the model to disambiguate (a heal trigger) instead of aborting. */
91
145
  class AmbiguousSelectorError extends CliError {
package/dist/rpc.js CHANGED
@@ -22,11 +22,15 @@ function describeError(e) {
22
22
  if (e instanceof errors_1.SelectorNotFoundError) {
23
23
  return { kind: 'SelectorNotFoundError', name: e.name, message: e.message, exitCode: e.exitCode };
24
24
  }
25
- // BEFORE the CliError arm — NoWindowError extends it, so a subclass check must come
26
- // first or the identity is flattened away. device/failover.ts classifies on
27
- // `instanceof NoWindowError` deliberately ("identity first, never message text"), and
28
- // losing it turns every mid-launch gap into an unknown that costs two device probes and
29
- // can quarantine a perfectly healthy phone.
25
+ // BEFORE the CliError arm — both transient reads extend it, so a subclass check must come
26
+ // first or the identity is flattened away. device/failover.ts classifies on the CLASS
27
+ // deliberately ("identity first, never message text"), and losing it turns every mid-launch
28
+ // gap into an unknown that costs two device probes and can quarantine a perfectly healthy
29
+ // phone. Flattening DumpKilledError also costs a poller its ride-out, which is the whole of
30
+ // issue #137 — and #137 was reported through a pooled `vk server`, i.e. across this wire.
31
+ if (e instanceof errors_1.DumpKilledError) {
32
+ return { kind: 'DumpKilledError', name: e.name, message: e.message, exitCode: e.exitCode };
33
+ }
30
34
  if (e instanceof errors_1.NoWindowError) {
31
35
  return { kind: 'NoWindowError', name: e.name, message: e.message, exitCode: e.exitCode };
32
36
  }
@@ -45,6 +49,8 @@ function rebuildError(d) {
45
49
  return new errors_1.SelectorNotFoundError(d.message);
46
50
  case 'NoWindowError':
47
51
  return new errors_1.NoWindowError(d.message);
52
+ case 'DumpKilledError':
53
+ return new errors_1.DumpKilledError(d.message);
48
54
  case 'CliError':
49
55
  return new errors_1.CliError(d.message, d.exitCode);
50
56
  default: {
package/dist/server.js CHANGED
@@ -58,6 +58,7 @@ const server_pool_1 = require("./server-pool");
58
58
  const ir_1 = require("./agent/ir");
59
59
  const rpc_1 = require("./rpc");
60
60
  const cli_1 = require("./cli");
61
+ const adb_health_1 = require("./adb-health");
61
62
  const wait_1 = require("./wait");
62
63
  const version_1 = require("./version");
63
64
  /**
@@ -89,6 +90,10 @@ const LOCK_IDLE_MS = 5 * 60 * 1000;
89
90
  // comfortably inside the client's 15-minute install ceiling at ~1 minute an install,
90
91
  // while a farm of ten wedged emulators cannot burn ten installs inside one request.
91
92
  const MAX_FAILOVER_HOPS = 2;
93
+ // How often to ask whether the host's adb server has rotted. Generous on purpose: the
94
+ // check shells out to `log show` (~1s) and the condition it looks for accumulates over
95
+ // DAYS, so a tight interval would buy nothing and spend host time on every idle server.
96
+ const ADB_RECYCLE_CHECK_MS = 10 * 60 * 1000;
92
97
  // Gap between the two probes that separate a momentary blip from a dead device. Mirrors
93
98
  // suite.ts's stillBroken, and for the same reason: a flaky dump also surfaces as exit 3,
94
99
  // so acting on one probe would rotate the pool on ordinary flake.
@@ -448,6 +453,72 @@ function buildServer(config) {
448
453
  // The first timer this server has ever had, so this is the first thing that could hold the
449
454
  // process open after Ctrl-C. It must not.
450
455
  reconcileTimer?.unref?.();
456
+ // --- adb server recycle ----------------------------------------------------
457
+ //
458
+ // A long-lived adb server leaks USB handles until it drops devices mid-run, and only a
459
+ // restart cures it (the measurements: `adb-health.ts`). `vk server` is BUILT to sit on a
460
+ // CI host for days — precisely the condition that rots it — so this is ON by default:
461
+ // on a dedicated box, recycling a broken transport is the expected behaviour, not a
462
+ // surprise. `VERIKUN_NO_ADB_RECYCLE=1` opts out, for the rare host running other adb
463
+ // work beside the server; that restores today's behaviour exactly, the equivalence
464
+ // `VERIKUN_NO_CLAIM` and `VERIKUN_NO_FAILOVER` are held to.
465
+ //
466
+ // Three properties are what make a DEFAULT-ON, HOST-GLOBAL restart safe:
467
+ //
468
+ // * EVIDENCE, NEVER AGE. A healthy server measures exactly zero violations, so a
469
+ // healthy host is never touched. Age alone would restart a perfectly good server on
470
+ // a timer — a new way to fail, which is the one thing this may not add.
471
+ // * FULLY IDLE ONLY. `kill-server` drops every transport on the machine, so anything
472
+ // mid-run vetoes. `othersActive` is the existing predicate for that and already
473
+ // steps over an idle lease, so a crashed client cannot wedge this forever.
474
+ // * ANDROID ONLY — and in practice macOS only, since `adbServerHealth` reports no
475
+ // evidence elsewhere and no-evidence is never rot. An iOS server never pays for it.
476
+ //
477
+ // The residual race is a client arriving during the ~2s restart and getting a busy
478
+ // error. Accepted deliberately: we only ever get here when adb is ALREADY broken, so
479
+ // that client's alternative was a server that drops its device mid-suite. An honest
480
+ // refusal beats a half-dead transport. `exclusive` is held across the restart so the
481
+ // refusal is the clean one the lease layer already knows how to give.
482
+ const adbRecycleOn = (0, adb_health_1.adbRecycleEnabled)(config.platform);
483
+ const RECYCLE_TOKEN = '__adb-recycle__';
484
+ const recycleAdbIfRotten = async () => {
485
+ // Cheap gate first: never shell out to `log show` while the server is working.
486
+ if (othersActive(RECYCLE_TOKEN) || inFlight.size > 0)
487
+ return;
488
+ const health = (0, adb_health_1.adbServerHealth)();
489
+ if (!(0, adb_health_1.adbServerRotting)(health))
490
+ return;
491
+ await serializeFailover(async () => {
492
+ // Re-check inside the queue: the health probe shells out for ~1s, which is ample
493
+ // time for a run to start, and by here we are about to cut every transport.
494
+ if (othersActive(RECYCLE_TOKEN) || inFlight.size > 0)
495
+ return;
496
+ (0, output_1.err)(`[server] ${(0, adb_health_1.describeRot)(health)}`);
497
+ exclusive = RECYCLE_TOKEN;
498
+ try {
499
+ const ok = (0, adb_health_1.recycleAdbServer)(process.env.ADB || 'adb');
500
+ (0, output_1.err)(ok ? '[server] adb server restarted — devices reconnecting' : '[server] adb server restart failed — continuing');
501
+ }
502
+ finally {
503
+ exclusive = null;
504
+ }
505
+ });
506
+ };
507
+ let recycling = false;
508
+ const recycleTimer = adbRecycleOn
509
+ ? setInterval(() => {
510
+ if (recycling)
511
+ return;
512
+ recycling = true;
513
+ void recycleAdbIfRotten()
514
+ .catch((e) => (0, output_1.err)(`[server] adb recycle check failed — ${(0, server_http_1.firstLine)(e.message)}`))
515
+ .finally(() => {
516
+ recycling = false;
517
+ });
518
+ }, ADB_RECYCLE_CHECK_MS)
519
+ : null;
520
+ // Like the reconcile timer: must never hold the process open at Ctrl-C.
521
+ recycleTimer?.unref?.();
451
522
  const pickFailoverDevice = (failed, reason) => serializeFailover(() => pickFailoverDeviceLocked(failed, reason));
452
523
  /**
453
524
  * Bring in a healthy replacement for `failed`. Returns the serial moved to, or null
@@ -1679,6 +1750,8 @@ function buildServer(config) {
1679
1750
  server.on('close', () => {
1680
1751
  if (reconcileTimer)
1681
1752
  clearInterval(reconcileTimer);
1753
+ if (recycleTimer)
1754
+ clearInterval(recycleTimer);
1682
1755
  dropRetainedInstall();
1683
1756
  });
1684
1757
  return server;
@@ -1906,6 +1979,14 @@ async function cmdServer(positionals, flags) {
1906
1979
  (0, output_1.err)('[server] NOTE: an authenticated client can now power-cycle AND erase this device.');
1907
1980
  }
1908
1981
  (0, output_1.err)(`[server] failover: ${failover.why}`);
1982
+ // Announced in both states, like the failover kill switch. ON is worth saying because
1983
+ // `adb kill-server` is host-global and an operator should not meet it as a surprise;
1984
+ // OFF is worth saying because a rotted adb server is otherwise a baffling flake.
1985
+ if (platform === 'android') {
1986
+ (0, output_1.err)(`[server] adb recycle: ${(0, adb_health_1.adbRecycleEnabled)(platform)
1987
+ ? 'on — a rotted adb server is restarted while idle (VERIKUN_NO_ADB_RECYCLE=1 to disable)'
1988
+ : 'disabled (VERIKUN_NO_ADB_RECYCLE)'}`);
1989
+ }
1909
1990
  (0, output_1.err)(serverLog
1910
1991
  ? `[server] log: ${serverLog.path} (--log-file ${server_log_1.LOG_OFF} to disable)`
1911
1992
  : `[server] log: stderr only (--log-file ${server_log_1.LOG_OFF})`);
package/dist/version.js CHANGED
@@ -3,4 +3,4 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.VERSION = void 0;
4
4
  // GENERATED by scripts/gen-version.mjs from package.json's "version" at build time
5
5
  // (the `prebuild` script). Do NOT edit by hand; bump package.json instead.
6
- exports.VERSION = '0.26.3';
6
+ exports.VERSION = '0.27.1';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "verikun",
3
- "version": "0.26.3",
3
+ "version": "0.27.1",
4
4
  "description": "Drive Android emulators/devices and iOS simulators for AI agents: tap, type, swipe, screenshot, and inspect the UI hierarchy by semantic identifiers — like Puppeteer for native apps.",
5
5
  "keywords": [
6
6
  "android",