verikun 0.29.0 → 0.30.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/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.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;
9
+ exports.AmbiguousSelectorError = exports.RunEvictedError = exports.NoFreeDeviceError = 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;
@@ -140,6 +140,44 @@ exports.dumpKilledMessage = dumpKilledMessage;
140
140
  exports.DUMP_KILLED_MESSAGE = 'The UI hierarchy dump was killed before it could answer — the device reclaiming memory ' +
141
141
  'while an app starts, or another tool holding the one UiAutomation connection. This ' +
142
142
  'normally clears within seconds.';
143
+ // --- Remote leases: a run that never got a device, and one that lost its device --------
144
+ //
145
+ // Both are exit 3 like any other environment failure — the exit-code contract does not move
146
+ // and `isEnvError` still holds. They are classes only so a PARALLEL suite can tell them apart
147
+ // from a broken box across a process boundary (`errorKind` in a lane child's --json). Read as
148
+ // ordinary environment failures they were issue #147: a pool that lost one phone recorded
149
+ // failures for tests that never ran, and retired a lane the server would have fed again
150
+ // minutes later.
151
+ /**
152
+ * A `vk server` refused this run a device: every device is leased (409) or none is serving
153
+ * (503). Built ONLY for `/v1/lease`, the call a run makes before it does anything, so it
154
+ * always means the test never started. `vk suite` hands such a test back to its queue and
155
+ * waits for a device instead of recording a failure.
156
+ */
157
+ class NoFreeDeviceError extends CliError {
158
+ constructor(message) {
159
+ super(message, 3);
160
+ this.name = 'NoFreeDeviceError';
161
+ }
162
+ }
163
+ exports.NoFreeDeviceError = NoFreeDeviceError;
164
+ /**
165
+ * The server ended this run part-way: its device left the pool, was taken over while the run
166
+ * sat idle, or was power-cycled — and a run is never re-homed onto another phone, because its
167
+ * earlier steps ran on this one. Only the server knows, so it TAGS the 409 (`errorKind`); an
168
+ * older server's eviction stays an ordinary environment failure.
169
+ *
170
+ * Nothing in the `vk ai` engine may absorb it: every later call is refused the same way, so a
171
+ * catch that treats it as a transient read turns a lost phone into an "absent" guard or a
172
+ * failed repair. `vk suite` re-runs the test as a fresh run without spending a retry.
173
+ */
174
+ class RunEvictedError extends CliError {
175
+ constructor(message) {
176
+ super(message, 3);
177
+ this.name = 'RunEvictedError';
178
+ }
179
+ }
180
+ exports.RunEvictedError = RunEvictedError;
143
181
  /** Selector matched >1 element. Exit 2. Carries the candidates so the agent runner
144
182
  * can ask the model to disambiguate (a heal trigger) instead of aborting. */
145
183
  class AmbiguousSelectorError extends CliError {
package/dist/report.js CHANGED
@@ -278,7 +278,8 @@ const SUITE_STYLE = `
278
278
  function suiteAttemptLinks(attempts, linkBase) {
279
279
  const links = attempts
280
280
  .map((a, i) => {
281
- const label = `attempt ${i + 1}`;
281
+ // Said outright, so nobody goes looking for a flake in a test whose phone was unplugged.
282
+ const label = `attempt ${i + 1}${a.evicted ? ' (device left the pool)' : ''}`;
282
283
  if (!a.id)
283
284
  return htmlEsc(label);
284
285
  return `<a href="${htmlEsc(`${linkBase}runs/${encodeURIComponent(a.id)}/report.html`)}">${htmlEsc(label)}</a>`;
package/dist/rpc.js CHANGED
@@ -34,6 +34,15 @@ function describeError(e) {
34
34
  if (e instanceof errors_1.NoWindowError) {
35
35
  return { kind: 'NoWindowError', name: e.name, message: e.message, exitCode: e.exitCode };
36
36
  }
37
+ // Also before the CliError arm, for a different reader: `mapError` puts this kind in a
38
+ // lane child's --json, and it is how a parallel suite tells "never got a device" and "lost
39
+ // its device" from a broken box (#147). The server only ever SENDS the eviction.
40
+ if (e instanceof errors_1.NoFreeDeviceError) {
41
+ return { kind: 'NoFreeDeviceError', name: e.name, message: e.message, exitCode: e.exitCode };
42
+ }
43
+ if (e instanceof errors_1.RunEvictedError) {
44
+ return { kind: 'RunEvictedError', name: e.name, message: e.message, exitCode: e.exitCode };
45
+ }
37
46
  if (e instanceof errors_1.CliError) {
38
47
  return { kind: 'CliError', name: e.name, message: e.message, exitCode: e.exitCode };
39
48
  }
@@ -51,6 +60,10 @@ function rebuildError(d) {
51
60
  return new errors_1.NoWindowError(d.message);
52
61
  case 'DumpKilledError':
53
62
  return new errors_1.DumpKilledError(d.message);
63
+ case 'NoFreeDeviceError':
64
+ return new errors_1.NoFreeDeviceError(d.message);
65
+ case 'RunEvictedError':
66
+ return new errors_1.RunEvictedError(d.message);
54
67
  case 'CliError':
55
68
  return new errors_1.CliError(d.message, d.exitCode);
56
69
  default: {
@@ -15,6 +15,7 @@ class HttpError extends Error {
15
15
  exitCode;
16
16
  deviceChanged;
17
17
  errorKind;
18
+ evicted;
18
19
  constructor(status, message, exitCode = status === 400 || status === 404 || status === 413 ? 2 : 3,
19
20
  /** Set when this request moved the server's device before failing — the client
20
21
  * needs to know the ground shifted even though the answer is an error. */
@@ -23,12 +24,15 @@ class HttpError extends Error {
23
24
  * HttpError is how the identity used to be lost: only `.message` and `.exitCode` were
24
25
  * copied across, so a `NoWindowError` reached the client as a bare `CliError`. Carry it
25
26
  * here and the outer catch can put it on the wire. */
26
- errorKind) {
27
+ errorKind,
28
+ /** This request's failure evicted the caller's run (see ExecResponse.evicted). */
29
+ evicted) {
27
30
  super(message);
28
31
  this.status = status;
29
32
  this.exitCode = exitCode;
30
33
  this.deviceChanged = deviceChanged;
31
34
  this.errorKind = errorKind;
35
+ this.evicted = evicted;
32
36
  this.name = 'HttpError';
33
37
  }
34
38
  }
package/dist/server.js CHANGED
@@ -891,11 +891,14 @@ function buildServer(config) {
891
891
  * phone that is now gone, so continuing it elsewhere would produce a run whose steps
892
892
  * came from two devices while the report named one. A new run token (a rerun) is served
893
893
  * normally — this only closes the door on the run that was interrupted.
894
+ *
895
+ * Each keeps what it lost and why, because the run is TOLD: its 409 names the device, and a
896
+ * parallel suite's warning then names the phone that left rather than a lane slot (#147).
894
897
  */
895
- const evicted = new Set();
898
+ const evicted = new Map();
896
899
  /**
897
900
  * Bounded, because on a long-lived CI server every interrupted run leaves an entry and
898
- * only its own `/v1/release` ever removes one. A Set iterates in insertion order, so
901
+ * only its own `/v1/release` ever removes one. A Map iterates in insertion order, so
899
902
  * the front is the stalest — a client that died long ago and will never ask again.
900
903
  */
901
904
  const EVICTED_CAP = 512;
@@ -906,17 +909,17 @@ function buildServer(config) {
906
909
  function evict(token, why) {
907
910
  const had = leases.get(token);
908
911
  leases.delete(token);
909
- evicted.add(token);
912
+ evicted.set(token, { serial: had?.serial, why });
910
913
  // Every eviction is announced. This is the direct cause of the 409 a client then reads
911
- // as an environment failure — and of the lane a suite retires over it — and it used to
912
- // be the one lease transition that happened in complete silence, so a degrading run
913
- // showed a burst of unexplained 409s with nothing anywhere connecting them to the
914
- // device that left.
914
+ // (a parallel suite re-runs the test as a fresh run; an older client read it as an
915
+ // environment failure and retired a lane over it), and it used to be the one lease
916
+ // transition that happened in complete silence, so a degrading run showed a burst of
917
+ // unexplained 409s with nothing anywhere connecting them to the device that left.
915
918
  if (had)
916
919
  (0, output_1.err)(`[server] lease: run ${token.slice(0, 8)}… evicted from ${had.serial} — ${why}`);
917
920
  // `if`, not `while`: this adds exactly one entry, so at most one can be over.
918
921
  if (evicted.size > EVICTED_CAP)
919
- evicted.delete(evicted.values().next().value);
922
+ evicted.delete(evicted.keys().next().value);
920
923
  }
921
924
  function evictHoldersOf(serial, why) {
922
925
  for (const [token, lease] of leases)
@@ -1025,11 +1028,14 @@ function buildServer(config) {
1025
1028
  */
1026
1029
  let exclusive = null;
1027
1030
  function busyError(token) {
1028
- if (token !== undefined && evicted.has(token)) {
1031
+ const lost = token === undefined ? undefined : evicted.get(token);
1032
+ if (lost) {
1029
1033
  // Named plainly, because "device is busy" would send the operator looking for a
1030
- // racing job that does not exist.
1031
- return new server_http_1.HttpError(409, 'the device this run was using left the pool and nothing healthy replaced it — ' +
1032
- 'start a fresh run; this one cannot continue on another device', 3);
1034
+ // racing job that does not exist. TAGGED, because the client cannot tell an eviction
1035
+ // from contention any other way: a parallel suite re-runs an evicted test as a fresh
1036
+ // run without spending a retry, but must never do that for a busy pool (#147).
1037
+ return new server_http_1.HttpError(409, `this run lost its device${lost.serial ? ` (${lost.serial})` : ''}: ${lost.why} — ` +
1038
+ 'start a fresh run; this one cannot continue on another device', 3, undefined, 'RunEvictedError');
1033
1039
  }
1034
1040
  // An empty pool never reaches here: the deviceless guard in the router answers 503 for
1035
1041
  // every route that takes a lease, and it names `lostDevice` while doing it. So `n` is
@@ -1119,7 +1125,7 @@ function buildServer(config) {
1119
1125
  evict(t, why);
1120
1126
  }
1121
1127
  }
1122
- async function handleExec(handle, req, res) {
1128
+ async function handleExec(handle, req, res, token) {
1123
1129
  const body = await (0, server_http_1.readBody)(req, EXEC_BODY_CAP);
1124
1130
  let parsed;
1125
1131
  try {
@@ -1160,7 +1166,12 @@ function buildServer(config) {
1160
1166
  }
1161
1167
  catch (e) {
1162
1168
  const changed = await considerFailover(e, 'exec', handle);
1163
- throw changed ? new server_http_1.HttpError(500, e.message, e instanceof errors_1.CliError ? e.exitCode : 3, changed) : e;
1169
+ // Evicted by the failover this very failure triggered: say so on the error itself (#147).
1170
+ const lost = evicted.has(token);
1171
+ if (lost && !changed) {
1172
+ throw new server_http_1.HttpError(500, e.message, e instanceof errors_1.CliError ? e.exitCode : 3, undefined, (0, rpc_1.describeError)(e).kind, true);
1173
+ }
1174
+ throw changed ? new server_http_1.HttpError(500, e.message, e instanceof errors_1.CliError ? e.exitCode : 3, changed, undefined, lost || undefined) : e;
1164
1175
  }
1165
1176
  const { code, error, step, artifacts, logStart } = outcome;
1166
1177
  (0, output_1.err)(`[server] ${handle.serial} exec ${node.command} ${node.positionals.join(' ')} → exit ${code} (${Date.now() - t0}ms)`);
@@ -1177,13 +1188,17 @@ function buildServer(config) {
1177
1188
  code,
1178
1189
  ...(error ? { error } : {}),
1179
1190
  ...(deviceChanged ? { deviceChanged } : {}),
1191
+ // The failover this failure triggered shed the device and evicted this run. MEASURED on
1192
+ // hardware: this response is the only one that knows — the client's next request is
1193
+ // usually its release, which clears the mark (#147).
1194
+ ...(evicted.has(token) ? { evicted: true } : {}),
1180
1195
  ...(step ? { step } : {}),
1181
1196
  ...(artifacts && Object.keys(artifacts).length ? { artifacts: (0, server_http_1.encodeArtifacts)(artifacts) } : {}),
1182
1197
  ...(logStart ? { logStart } : {}),
1183
1198
  };
1184
1199
  (0, server_http_1.sendJson)(res, 200, payload);
1185
1200
  }
1186
- async function handleElements(handle, req, res) {
1201
+ async function handleElements(handle, req, res, token) {
1187
1202
  await (0, server_http_1.readBody)(req, EXEC_BODY_CAP); // drain (the body is unused; keeps keep-alive sane)
1188
1203
  try {
1189
1204
  const elements = await handle.elements(); // CliError(3) on dump failure → 500 below
@@ -1198,12 +1213,13 @@ function buildServer(config) {
1198
1213
  // hierarchy from somewhere else is worse than an error. The client's connect probe
1199
1214
  // re-asks after a reported move — see remote.ts's preflight.
1200
1215
  const deviceChanged = await considerFailover(e, 'read', handle);
1201
- if (!deviceChanged)
1216
+ const lost = evicted.has(token);
1217
+ if (!deviceChanged && !lost)
1202
1218
  throw e;
1203
1219
  // describeError, not just .message/.exitCode: this wrap is on the path a mid-launch
1204
1220
  // NoWindowError takes, and the engine's guard tells "still drawing" from "box broken"
1205
1221
  // by class alone (issue #80).
1206
- throw new server_http_1.HttpError(500, e.message, e instanceof errors_1.CliError ? e.exitCode : 3, deviceChanged, (0, rpc_1.describeError)(e).kind);
1222
+ throw new server_http_1.HttpError(500, e.message, e instanceof errors_1.CliError ? e.exitCode : 3, deviceChanged, (0, rpc_1.describeError)(e).kind, lost || undefined);
1207
1223
  }
1208
1224
  }
1209
1225
  async function handleLogs(handle, req, res) {
@@ -1771,6 +1787,13 @@ function buildServer(config) {
1771
1787
  (0, server_http_1.sendJson)(res, 200, body);
1772
1788
  return;
1773
1789
  }
1790
+ // A run that was EVICTED is told so first, however empty the pool has since become: it
1791
+ // lost its phone part-way, which is not the same as a new run finding none, and a
1792
+ // parallel suite re-runs only the former as a fresh run (#147). Shedding a pool's last
1793
+ // device is exactly when both are true at once.
1794
+ if (served.size === 0 && evicted.has(token) && (path === '/v1/exec' || path === '/v1/elements' || path === '/v1/logs')) {
1795
+ throw busyError(token);
1796
+ }
1774
1797
  // A deviceless server must not silently fail every command. In-memory check, so
1775
1798
  // the normal path is untouched. NOTE this fires only when the server NEVER
1776
1799
  // resolved a device — one that DIED mid-run still has a non-null binding and keeps
@@ -1811,9 +1834,9 @@ function buildServer(config) {
1811
1834
  return holdingLease(token, () => fn(h));
1812
1835
  };
1813
1836
  if (req.method === 'POST' && path === '/v1/exec')
1814
- return onLeasedDevice((h) => handleExec(h, req, res));
1837
+ return onLeasedDevice((h) => handleExec(h, req, res, token));
1815
1838
  if (req.method === 'POST' && path === '/v1/elements')
1816
- return onLeasedDevice((h) => handleElements(h, req, res));
1839
+ return onLeasedDevice((h) => handleElements(h, req, res, token));
1817
1840
  if (req.method === 'POST' && path === '/v1/logs')
1818
1841
  return onLeasedDevice((h) => handleLogs(h, req, res));
1819
1842
  if (req.method === 'POST' && path === '/v1/install') {
@@ -1891,6 +1914,7 @@ function buildServer(config) {
1891
1914
  exitCode: mapped.exitCode,
1892
1915
  ...(errorKind ? { errorKind } : {}),
1893
1916
  ...(mapped.deviceChanged ? { deviceChanged: mapped.deviceChanged } : {}),
1917
+ ...(mapped.evicted ? { evicted: true } : {}),
1894
1918
  };
1895
1919
  (0, server_http_1.sendJson)(res, mapped.status, body);
1896
1920
  }