verikun 0.28.0 → 0.29.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/.claude/skills/verikun/SKILL.md +5 -0
- package/CHANGELOG.md +19 -1
- package/dist/agent/remote.js +75 -18
- package/dist/device/failover.js +7 -2
- package/dist/drivers/adb.js +13 -7
- package/dist/install-timeouts.js +12 -0
- package/dist/server.js +71 -16
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -514,6 +514,11 @@ installs again, warning on stderr that **that build's app data is gone**. A same
|
|
|
514
514
|
install still keeps its data. Do not treat the warning as a failure — it is how the
|
|
515
515
|
install succeeded. iOS has no equivalent recovery; the install just fails there.
|
|
516
516
|
|
|
517
|
+
**`vk install` also allows a version downgrade by default, when the build is debuggable.**
|
|
518
|
+
A build with a lower version code than what's already on the device installs normally
|
|
519
|
+
instead of failing with `INSTALL_FAILED_VERSION_DOWNGRADE`. A release-signed build is a
|
|
520
|
+
genuine exception — Android still refuses it, and that is not something to retry.
|
|
521
|
+
|
|
517
522
|
`vk server --devices all` (or `all-android` / `all-ios` / a serial list) serves a **pool**
|
|
518
523
|
from one address. Each run leases one device for its whole life, so a run's steps and
|
|
519
524
|
repairs always land on the same phone. `vk install --server` then installs on every device;
|
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,25 @@ All notable changes to this project are documented here. The format is based on
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.29.0] - 2026-09-16
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
- **`vk install`** (Android) allows a version downgrade by default (`adb install -d`),
|
|
13
|
+
instead of failing with `INSTALL_FAILED_VERSION_DOWNGRADE`, when the build is debuggable.
|
|
14
|
+
|
|
15
|
+
## [0.28.1] - 2026-09-15
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- **`vk install --server`** sheds a pooled device whose install stalls, while the client now
|
|
19
|
+
honors its 15-minute request budget. ([#143])
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
- **Docs rule**: a bug fix is changelog-only; `docs/` changes only when a contract moves or a page
|
|
23
|
+
went false. ([#140])
|
|
24
|
+
|
|
25
|
+
[#140]: https://github.com/ddikman/verikun/issues/140
|
|
26
|
+
[#143]: https://github.com/ddikman/verikun/issues/143
|
|
27
|
+
|
|
9
28
|
## [0.28.0] - 2026-09-14
|
|
10
29
|
|
|
11
30
|
A device that has gone now leaves a pooled `vk server` instead of being dealt out until
|
|
@@ -26,7 +45,6 @@ somebody notices.
|
|
|
26
45
|
partial failure. Older clients see a success with an unknown field. ([#139])
|
|
27
46
|
|
|
28
47
|
[#139]: https://github.com/ddikman/verikun/issues/139
|
|
29
|
-
|
|
30
48
|
## [0.27.1] - 2026-09-14
|
|
31
49
|
|
|
32
50
|
A hierarchy read the device killed is now waited out instead of ending the test.
|
package/dist/agent/remote.js
CHANGED
|
@@ -17,18 +17,21 @@ exports.remoteDeviceList = remoteDeviceList;
|
|
|
17
17
|
exports.createRemoteBackend = createRemoteBackend;
|
|
18
18
|
const node_fs_1 = require("node:fs");
|
|
19
19
|
const node_crypto_1 = require("node:crypto");
|
|
20
|
+
const node_http_1 = require("node:http");
|
|
21
|
+
const node_https_1 = require("node:https");
|
|
20
22
|
const node_path_1 = require("node:path");
|
|
21
23
|
const errors_1 = require("../errors");
|
|
24
|
+
const install_timeouts_1 = require("../install-timeouts");
|
|
22
25
|
const output_1 = require("../output");
|
|
23
26
|
const rpc_1 = require("../rpc");
|
|
24
27
|
/**
|
|
25
|
-
* The ceiling
|
|
28
|
+
* The ceiling fetch-based calls below cannot exceed, whatever their own budgets say.
|
|
26
29
|
*
|
|
27
30
|
* Node's global `fetch` is undici, whose `headersTimeout` and `bodyTimeout` both default to
|
|
28
31
|
* 300s, and there is no dependency-free way to raise them: a `dispatcher` needs `undici`
|
|
29
|
-
* itself, which is bundled but not importable.
|
|
30
|
-
*
|
|
31
|
-
* 300s.
|
|
32
|
+
* itself, which is bundled but not importable. Long-running install uploads therefore use
|
|
33
|
+
* Node's built-in http/https client instead; its explicit 15-minute timer is the real
|
|
34
|
+
* upload-and-response ceiling. The other AbortControllers remain a floor when set above 300s.
|
|
32
35
|
*
|
|
33
36
|
* MEASURED on Node v20.20.2 against a server that held its headers for 310s: the fetch
|
|
34
37
|
* rejected at 301s with `TypeError: fetch failed`, cause `HeadersTimeoutError`, code
|
|
@@ -39,11 +42,10 @@ const rpc_1 = require("../rpc");
|
|
|
39
42
|
const FETCH_HEADERS_CEILING_MS = 300_000;
|
|
40
43
|
// Per-call ceilings. exec is generous: a single leaf may legitimately block for its
|
|
41
44
|
// whole auto-wait window or an explicit `wait --timeout`, plus device time. Anything here
|
|
42
|
-
// above FETCH_HEADERS_CEILING_MS is aspirational
|
|
45
|
+
// above FETCH_HEADERS_CEILING_MS is aspirational unless it uses requestWithNodeHttp.
|
|
43
46
|
const HEALTH_TIMEOUT_MS = 10_000;
|
|
44
47
|
const ELEMENTS_TIMEOUT_MS = 60_000;
|
|
45
48
|
const EXEC_TIMEOUT_MS = 10 * 60_000;
|
|
46
|
-
const INSTALL_TIMEOUT_MS = 15 * 60_000;
|
|
47
49
|
const DEVICE_LIST_TIMEOUT_MS = 30_000;
|
|
48
50
|
// Meant to sit above the server's own 4-minute boot ceiling, so the SERVER reports why a
|
|
49
51
|
// boot timed out rather than the client aborting first. It is exactly AT
|
|
@@ -52,6 +54,54 @@ const DEVICE_LIST_TIMEOUT_MS = 30_000;
|
|
|
52
54
|
const DEVICE_START_TIMEOUT_MS = 5 * 60_000;
|
|
53
55
|
const DEVICE_STOP_TIMEOUT_MS = 60_000;
|
|
54
56
|
const trimUrl = (url) => url.replace(/\/+$/, '');
|
|
57
|
+
/**
|
|
58
|
+
* A dependency-free request path whose caller-owned timeout covers BOTH upload and response.
|
|
59
|
+
*
|
|
60
|
+
* Global fetch cannot wait past undici's fixed 300s header/body ceilings. An install may
|
|
61
|
+
* legitimately need 15 minutes, so it uses Node's native protocol clients instead of claiming
|
|
62
|
+
* a budget the transport cannot honor. Buffering the response preserves the Response boundary
|
|
63
|
+
* used below; install responses are small JSON descriptors, never artifacts.
|
|
64
|
+
*/
|
|
65
|
+
function requestWithNodeHttp(url, method, headers, body, timeoutMs) {
|
|
66
|
+
const endpoint = new URL(url);
|
|
67
|
+
const send = endpoint.protocol === 'http:' ? node_http_1.request : endpoint.protocol === 'https:' ? node_https_1.request : null;
|
|
68
|
+
if (!send)
|
|
69
|
+
return Promise.reject(new Error(`unsupported server protocol '${endpoint.protocol}'`));
|
|
70
|
+
const payload = body === undefined ? undefined : Buffer.isBuffer(body) ? body : Buffer.from(body);
|
|
71
|
+
const requestHeaders = {
|
|
72
|
+
...headers,
|
|
73
|
+
...(payload ? { 'content-length': String(payload.byteLength) } : {}),
|
|
74
|
+
};
|
|
75
|
+
return new Promise((resolve, reject) => {
|
|
76
|
+
let settled = false;
|
|
77
|
+
let timer;
|
|
78
|
+
const finish = (fn) => {
|
|
79
|
+
if (settled)
|
|
80
|
+
return;
|
|
81
|
+
settled = true;
|
|
82
|
+
if (timer)
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
fn();
|
|
85
|
+
};
|
|
86
|
+
const req = send(endpoint, { method, headers: requestHeaders }, (res) => {
|
|
87
|
+
const chunks = [];
|
|
88
|
+
res.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
|
|
89
|
+
res.on('error', (e) => finish(() => reject(e)));
|
|
90
|
+
res.on('aborted', () => finish(() => reject(new Error('the server aborted its response'))));
|
|
91
|
+
res.on('end', () => {
|
|
92
|
+
const status = res.statusCode ?? 500;
|
|
93
|
+
finish(() => resolve(new Response(Buffer.concat(chunks), { status })));
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
req.on('error', (e) => finish(() => reject(e)));
|
|
97
|
+
timer = setTimeout(() => {
|
|
98
|
+
const timeout = Object.assign(new Error('This operation was aborted'), { name: 'AbortError' });
|
|
99
|
+
req.destroy(timeout);
|
|
100
|
+
}, timeoutMs);
|
|
101
|
+
timer.unref?.();
|
|
102
|
+
req.end(payload);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
55
105
|
/**
|
|
56
106
|
* Turn a non-2xx into the error the caller sees.
|
|
57
107
|
*
|
|
@@ -132,24 +182,31 @@ class RemoteTransport {
|
|
|
132
182
|
h.authorization = `Bearer ${this.opts.authKey}`;
|
|
133
183
|
return h;
|
|
134
184
|
}
|
|
135
|
-
async request(method, path, body, timeoutMs, extraHeaders = {}) {
|
|
185
|
+
async request(method, path, body, timeoutMs, extraHeaders = {}, transport = 'fetch') {
|
|
136
186
|
const url = `${this.base}${path}`;
|
|
137
|
-
|
|
138
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
187
|
+
let timer;
|
|
139
188
|
let res;
|
|
140
189
|
try {
|
|
141
|
-
|
|
142
|
-
method,
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
190
|
+
if (transport === 'node-http') {
|
|
191
|
+
res = await requestWithNodeHttp(url, method, this.headers(extraHeaders), body, timeoutMs);
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
const controller = new AbortController();
|
|
195
|
+
timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
196
|
+
res = await fetch(url, {
|
|
197
|
+
method,
|
|
198
|
+
headers: this.headers(extraHeaders),
|
|
199
|
+
body,
|
|
200
|
+
signal: controller.signal,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
147
203
|
}
|
|
148
204
|
catch (e) {
|
|
149
205
|
throw new errors_1.CliError(`cannot reach verikun server at ${url} (${transportReason(e, timeoutMs)})`, 3);
|
|
150
206
|
}
|
|
151
207
|
finally {
|
|
152
|
-
|
|
208
|
+
if (timer)
|
|
209
|
+
clearTimeout(timer);
|
|
153
210
|
}
|
|
154
211
|
if (!res.ok) {
|
|
155
212
|
const body = await readBody(res);
|
|
@@ -262,11 +319,11 @@ function createRemoteBackend(opts, health) {
|
|
|
262
319
|
throw new errors_1.CliError(`install: cannot read '${appPath}' (${e.message})`, 2);
|
|
263
320
|
}
|
|
264
321
|
const sha256 = (0, node_crypto_1.createHash)('sha256').update(buf).digest('hex');
|
|
265
|
-
const res = await t.request('POST', '/v1/install', buf,
|
|
322
|
+
const res = await t.request('POST', '/v1/install', buf, install_timeouts_1.REMOTE_INSTALL_TIMEOUT_MS, {
|
|
266
323
|
'content-type': 'application/octet-stream',
|
|
267
324
|
'x-verikun-ext': ext,
|
|
268
325
|
'x-verikun-sha256': sha256,
|
|
269
|
-
});
|
|
326
|
+
}, 'node-http');
|
|
270
327
|
// Install is the one operation the server replays elsewhere, so a move here means
|
|
271
328
|
// the build DID land — on a different device than the one we started with.
|
|
272
329
|
if (res.deviceChanged)
|
package/dist/device/failover.js
CHANGED
|
@@ -33,6 +33,7 @@ const ARTIFACT_RULES = [
|
|
|
33
33
|
const TRANSIENT_RULES = [[/INSTALL_FAILED_ABORTED/, 'the install session was aborted']];
|
|
34
34
|
// --- fast paths: name the reason and skip the probe. NEVER the gate. ----------
|
|
35
35
|
const UNREACHABLE_RULES = [
|
|
36
|
+
[/stopped responding.*install/i, 'the device stopped responding during install'],
|
|
36
37
|
[/device (?:'[^']*' )?not found/i, 'the device is not attached'],
|
|
37
38
|
[/no devices\/emulators found/i, 'no device is attached'],
|
|
38
39
|
[/device offline/i, 'the device is offline'],
|
|
@@ -49,6 +50,9 @@ const DEVICE_STATE_RULES = [
|
|
|
49
50
|
// `install` already tried removing it and reinstalling (drivers/adb.ts); reaching here
|
|
50
51
|
// means that did not work, so the wording must not imply nothing was attempted.
|
|
51
52
|
[/INSTALL_FAILED_UPDATE_INCOMPATIBLE/, 'a differently-signed build could not be replaced on the device'],
|
|
53
|
+
// `install` already retries with `-d` (drivers/adb.ts); reaching here means the build
|
|
54
|
+
// or device isn't debuggable, so Android is still refusing — a genuine per-device
|
|
55
|
+
// conflict, not a sign the retry never ran.
|
|
52
56
|
[/INSTALL_FAILED_VERSION_DOWNGRADE/, 'the device holds a newer build of this package'],
|
|
53
57
|
[/INSTALL_FAILED_ALREADY_EXISTS/, 'the package is already installed on the device'],
|
|
54
58
|
[/INSTALL_FAILED_DUPLICATE_PERMISSION/, 'another app on the device declares one of these permissions'],
|
|
@@ -166,8 +170,9 @@ function classifyInstallFailure(e) {
|
|
|
166
170
|
if (named)
|
|
167
171
|
return { move: true, kind: 'device-state', reason: named };
|
|
168
172
|
// The inversion. Not in the denylist ⇒ it is about the device, even though we have
|
|
169
|
-
// never seen this wording. Bounded by
|
|
170
|
-
// FIRST device's error on exhaustion, so a wrong guess costs time,
|
|
173
|
+
// never seen this wording. Bounded by the request-derived install hop limit, and the
|
|
174
|
+
// caller reports the FIRST device's error on exhaustion, so a wrong guess costs time,
|
|
175
|
+
// not diagnosis.
|
|
171
176
|
return { move: true, kind: 'device-state', reason: 'the device could not install this build', unclassified: true };
|
|
172
177
|
}
|
|
173
178
|
return classify(e, UNKNOWN_STAY);
|
package/dist/drivers/adb.js
CHANGED
|
@@ -1104,21 +1104,27 @@ class AdbDriver {
|
|
|
1104
1104
|
throw new errors_1.CliError(signatureConflictHelp(appPath, pkg, 'retry-failed', second), 3);
|
|
1105
1105
|
}
|
|
1106
1106
|
/**
|
|
1107
|
-
* One `adb install -r` attempt: null on success, else adb's collapsed output.
|
|
1107
|
+
* One `adb install -r -d` attempt: null on success, else adb's collapsed output.
|
|
1108
1108
|
*
|
|
1109
1109
|
* Returns rather than throws because the caller has to READ the failure to decide
|
|
1110
1110
|
* whether it is recoverable — and a thrown-and-caught CliError would put the final
|
|
1111
1111
|
* message's own prefix inside the message it is composing.
|
|
1112
1112
|
*
|
|
1113
1113
|
* `-r` reinstalls over an existing package keeping its data (the common
|
|
1114
|
-
* update-the-build-under-test case).
|
|
1115
|
-
*
|
|
1116
|
-
*
|
|
1117
|
-
*
|
|
1118
|
-
*
|
|
1114
|
+
* update-the-build-under-test case). `-d` allows a lower versionCode to replace a
|
|
1115
|
+
* higher one already on the device — unconditional here, not a retry like the
|
|
1116
|
+
* signature-conflict replace below, because it carries no data-loss trade-off (inert
|
|
1117
|
+
* when there is no downgrade, and keeps data the same way `-r` does when there is).
|
|
1118
|
+
* Android only honors it for a debuggable build, so a release-signed downgrade still
|
|
1119
|
+
* surfaces `INSTALL_FAILED_VERSION_DOWNGRADE` unresolved — see the comment on that
|
|
1120
|
+
* code in `device/failover.ts`. A large APK can legitimately take minutes to stream +
|
|
1121
|
+
* install, so the timeout is far above the 30s default. adb reports failures both as a
|
|
1122
|
+
* non-zero exit AND as a `Failure [REASON]` line on stdout with exit 0 (varies by adb
|
|
1123
|
+
* version) — check both, and require `Success` positively rather than merely inferring
|
|
1124
|
+
* it from the absence of `Failure`.
|
|
1119
1125
|
*/
|
|
1120
1126
|
tryInstall(appPath) {
|
|
1121
|
-
const r = (0, exec_1.runText)(ADB, this.withSerial(['install', '-r', appPath]), { timeout: 10 * 60 * 1000 });
|
|
1127
|
+
const r = (0, exec_1.runText)(ADB, this.withSerial(['install', '-r', '-d', appPath]), { timeout: 10 * 60 * 1000 });
|
|
1122
1128
|
const combined = `${r.stdout}\n${r.stderr}`;
|
|
1123
1129
|
if (r.code !== 0 || /^Failure\b/im.test(combined) || !/^Success\b/im.test(combined)) {
|
|
1124
1130
|
return combined.replace(/\s+/g, ' ').trim() || `exit code ${r.code}`;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// The remote-install time budget is shared by the client that owns the request and the
|
|
3
|
+
// server that may retry it. Keeping the numbers together prevents a server-side retry plan
|
|
4
|
+
// that can never finish before the client gives up.
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.MAX_INSTALL_FAILOVER_HOPS = exports.INSTALL_DEVICE_TIMEOUT_MS = exports.REMOTE_INSTALL_TIMEOUT_MS = void 0;
|
|
7
|
+
/** Whole upload + install request, enforced by the remote client. */
|
|
8
|
+
exports.REMOTE_INSTALL_TIMEOUT_MS = 15 * 60_000;
|
|
9
|
+
/** One device gets four minutes; a measured ~170s large install still has useful margin. */
|
|
10
|
+
exports.INSTALL_DEVICE_TIMEOUT_MS = 4 * 60_000;
|
|
11
|
+
/** Three attempts consume at most 12m, leaving 3m of the request budget for the upload. */
|
|
12
|
+
exports.MAX_INSTALL_FAILOVER_HOPS = Math.max(0, Math.floor(exports.REMOTE_INSTALL_TIMEOUT_MS / exports.INSTALL_DEVICE_TIMEOUT_MS) - 1);
|
package/dist/server.js
CHANGED
|
@@ -45,6 +45,7 @@ const node_stream_1 = require("node:stream");
|
|
|
45
45
|
const promises_1 = require("node:stream/promises");
|
|
46
46
|
const args_1 = require("./args");
|
|
47
47
|
const errors_1 = require("./errors");
|
|
48
|
+
const install_timeouts_1 = require("./install-timeouts");
|
|
48
49
|
const drivers_1 = require("./drivers");
|
|
49
50
|
const manager_1 = require("./companion/manager");
|
|
50
51
|
const claims_1 = require("./device/claims");
|
|
@@ -86,10 +87,6 @@ const INSTALL_BODY_CAP = 512 * 1024 * 1024; // 512 MB app build
|
|
|
86
87
|
// to survive a client-side compile/repair pause, short enough that a crashed
|
|
87
88
|
// caller doesn't wedge the device.
|
|
88
89
|
const LOCK_IDLE_MS = 5 * 60 * 1000;
|
|
89
|
-
// How many times ONE request may move device. 2 moves = 3 devices tried, which sits
|
|
90
|
-
// comfortably inside the client's 15-minute install ceiling at ~1 minute an install,
|
|
91
|
-
// while a farm of ten wedged emulators cannot burn ten installs inside one request.
|
|
92
|
-
const MAX_FAILOVER_HOPS = 2;
|
|
93
90
|
// How often to ask whether the host's adb server has rotted. Generous on purpose: the
|
|
94
91
|
// check shells out to `log show` (~1s) and the condition it looks for accumulates over
|
|
95
92
|
// DAYS, so a tight interval would buy nothing and spend host time on every idle server.
|
|
@@ -108,6 +105,16 @@ const ADB_RECYCLE_CHECK_MS = 10 * 60 * 1000;
|
|
|
108
105
|
*/
|
|
109
106
|
const RECONCILE_INTERVAL_MS = 60_000;
|
|
110
107
|
const PROBE_RETRY_MS = 1000;
|
|
108
|
+
/** Kept distinct so an all-device timeout is never mistaken for evidence of a bad build. */
|
|
109
|
+
class InstallAttemptTimeoutError extends errors_1.CliError {
|
|
110
|
+
constructor(serial, timeoutMs) {
|
|
111
|
+
const duration = timeoutMs >= 1000 ? `${Math.round(timeoutMs / 1000)}s` : `${timeoutMs}ms`;
|
|
112
|
+
super(`device ${serial} stopped responding: install exceeded its ${duration} per-device deadline`, 3);
|
|
113
|
+
this.name = 'InstallAttemptTimeoutError';
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** Failover wraps an exhausted attempt in HttpError, so retain a wire-safe marker too. */
|
|
117
|
+
const isInstallAttemptTimeout = (e) => e instanceof InstallAttemptTimeoutError || (e instanceof Error && /per-device deadline/.test(e.message));
|
|
111
118
|
// Deliberately below the client's 5-minute ceiling, so a slow boot is reported by the
|
|
112
119
|
// side that knows WHY ("did not finish booting within 240s") rather than as a generic
|
|
113
120
|
// client-side abort.
|
|
@@ -1251,7 +1258,7 @@ function buildServer(config) {
|
|
|
1251
1258
|
* On exhaustion it throws the FIRST device's error, never the last. That inversion is
|
|
1252
1259
|
* what makes move-by-default safe: a wrong move costs time, not the diagnosis.
|
|
1253
1260
|
*/
|
|
1254
|
-
async function installWithFailover(serial, tmpPath) {
|
|
1261
|
+
async function installWithFailover(serial, tmpPath, timedOut) {
|
|
1255
1262
|
let change;
|
|
1256
1263
|
let moves = 0;
|
|
1257
1264
|
let firstError;
|
|
@@ -1291,7 +1298,7 @@ function buildServer(config) {
|
|
|
1291
1298
|
// are still on our disk), so it hops rather than throwing a bare 503 that never
|
|
1292
1299
|
// reaches the failover machinery at all.
|
|
1293
1300
|
const gone = firstError ?? new server_http_1.HttpError(503, `device ${from} is no longer attached`, 3);
|
|
1294
|
-
if (!config.failover || hop >=
|
|
1301
|
+
if (!config.failover || hop >= install_timeouts_1.MAX_INSTALL_FAILOVER_HOPS)
|
|
1295
1302
|
throw gone;
|
|
1296
1303
|
// `unreachable` is the literal truth — it is not in the pool — and it is also
|
|
1297
1304
|
// inert here: `shrink` sees a non-member and takes its cleanup tail either way.
|
|
@@ -1299,7 +1306,28 @@ function buildServer(config) {
|
|
|
1299
1306
|
continue;
|
|
1300
1307
|
}
|
|
1301
1308
|
try {
|
|
1302
|
-
|
|
1309
|
+
const timeoutMs = config.installAttemptMs ?? install_timeouts_1.INSTALL_DEVICE_TIMEOUT_MS;
|
|
1310
|
+
await new Promise((resolve, reject) => {
|
|
1311
|
+
let settled = false;
|
|
1312
|
+
const finish = (fn) => {
|
|
1313
|
+
if (settled)
|
|
1314
|
+
return;
|
|
1315
|
+
settled = true;
|
|
1316
|
+
clearTimeout(timer);
|
|
1317
|
+
fn();
|
|
1318
|
+
};
|
|
1319
|
+
const timer = setTimeout(() => {
|
|
1320
|
+
const timeout = new InstallAttemptTimeoutError(from, timeoutMs);
|
|
1321
|
+
// A race alone would leave the worker blocked in adb forever. Retiring it is
|
|
1322
|
+
// what releases the transport and makes the deadline a recovery mechanism.
|
|
1323
|
+
timedOut.add(from);
|
|
1324
|
+
quarantineDevice(from, timeout.message);
|
|
1325
|
+
pool.retire(from);
|
|
1326
|
+
finish(() => reject(timeout));
|
|
1327
|
+
}, timeoutMs);
|
|
1328
|
+
timer.unref?.();
|
|
1329
|
+
void handle.install(tmpPath).then(() => finish(resolve), (e) => finish(() => reject(e)));
|
|
1330
|
+
});
|
|
1303
1331
|
return { change, moves };
|
|
1304
1332
|
}
|
|
1305
1333
|
catch (e) {
|
|
@@ -1310,8 +1338,18 @@ function buildServer(config) {
|
|
|
1310
1338
|
noteVerdict(verdict, e, 'install');
|
|
1311
1339
|
// The artifact is broken / the caller is wrong / failover is off / we are out of
|
|
1312
1340
|
// hops: report the first failure unchanged, exactly as before this feature.
|
|
1313
|
-
if (!verdict.move || !config.failover || hop >=
|
|
1341
|
+
if (!verdict.move || !config.failover || hop >= install_timeouts_1.MAX_INSTALL_FAILOVER_HOPS) {
|
|
1342
|
+
// A timeout retired its worker before this catch. If it cannot move, finish the
|
|
1343
|
+
// cleanup that pickFailoverDevice would otherwise own; especially, never leave
|
|
1344
|
+
// a host-global claim pinned to a worker that no longer exists.
|
|
1345
|
+
if (isInstallAttemptTimeout(e)) {
|
|
1346
|
+
evictHoldersOf(from, `${from} left the pool after its install timed out`);
|
|
1347
|
+
(0, manager_1.releaseCompanionOn)(from);
|
|
1348
|
+
if ((0, claims_1.claimsEnabled)(claimEnv))
|
|
1349
|
+
(0, claims_1.releaseClaim)(from, { ...claimOpts, mineOnly: true });
|
|
1350
|
+
}
|
|
1314
1351
|
throw firstError;
|
|
1352
|
+
}
|
|
1315
1353
|
from = await hopOrThrow(verdict.reason, firstError, verdict.kind);
|
|
1316
1354
|
}
|
|
1317
1355
|
}
|
|
@@ -1361,9 +1399,10 @@ function buildServer(config) {
|
|
|
1361
1399
|
throw new server_http_1.HttpError(503, `no device is left to install onto${lostDevice ? ` — last loss: ${lostDevice}` : ''}`, 3);
|
|
1362
1400
|
}
|
|
1363
1401
|
(0, output_1.err)(`[server] install: received ${size} bytes (.${ext}), installing on ${targets.join(', ')}…`);
|
|
1402
|
+
const timedOut = new Set();
|
|
1364
1403
|
const outcomes = await Promise.all(targets.map(async (serial) => {
|
|
1365
1404
|
try {
|
|
1366
|
-
return { serial, ...(await installWithFailover(serial, tmpPath)), error: null };
|
|
1405
|
+
return { serial, ...(await installWithFailover(serial, tmpPath, timedOut)), error: null };
|
|
1367
1406
|
}
|
|
1368
1407
|
catch (e) {
|
|
1369
1408
|
return { serial, change: undefined, moves: 0, error: e };
|
|
@@ -1387,9 +1426,21 @@ function buildServer(config) {
|
|
|
1387
1426
|
// polarity, since the device side is open-ended and OEM-specific. That is right
|
|
1388
1427
|
// for ONE device failing; applied to every device at once it condemns the whole
|
|
1389
1428
|
// pool for what this very branch has just concluded is a bad build. Undo them.
|
|
1390
|
-
const
|
|
1391
|
-
if (
|
|
1392
|
-
|
|
1429
|
+
const hasTimeout = timedOut.size > 0;
|
|
1430
|
+
if (!hasTimeout) {
|
|
1431
|
+
// No lane succeeded and none hung: the common input is now the stronger
|
|
1432
|
+
// suspect, so undo the per-device quarantines made by the move-by-default
|
|
1433
|
+
// classifier.
|
|
1434
|
+
const condemned = targets.filter((t) => quarantine.delete(t));
|
|
1435
|
+
if (condemned.length) {
|
|
1436
|
+
(0, output_1.err)(`[server] install: failed on every device, so the build is the suspect — un-quarantining ${condemned.join(', ')}`);
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
else {
|
|
1440
|
+
// A deadline is direct evidence about a device, not the artifact. Preserve
|
|
1441
|
+
// every quarantine when any lane timed out; otherwise the next request could
|
|
1442
|
+
// immediately be dealt the same wedged worker again.
|
|
1443
|
+
(0, output_1.err)('[server] install: every device failed and at least one timed out — keeping the device quarantines');
|
|
1393
1444
|
}
|
|
1394
1445
|
throw failed[0].error;
|
|
1395
1446
|
}
|
|
@@ -1412,11 +1463,15 @@ function buildServer(config) {
|
|
|
1412
1463
|
skipped = failed.map((f) => {
|
|
1413
1464
|
const serial = landedOn(f);
|
|
1414
1465
|
const reason = (0, server_http_1.firstLine)(f.error.message);
|
|
1415
|
-
|
|
1466
|
+
const serving = pool.serials().includes(serial);
|
|
1467
|
+
if (serving)
|
|
1416
1468
|
pool.retire(serial);
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1469
|
+
// Same two rules as the shed in `shrink`: `degraded` is for MEMBERS, and a
|
|
1470
|
+
// device that is not serving belongs in `quarantine` — which `rejoinDevice`
|
|
1471
|
+
// clears on the evidence of a worker that started and a build that installed.
|
|
1472
|
+
// The timeout path retired its worker already, but still needs this common
|
|
1473
|
+
// bookkeeping tail. Other already-absent failures keep their existing handling.
|
|
1474
|
+
if (serving || isInstallAttemptTimeout(f.error)) {
|
|
1420
1475
|
degraded.delete(serial);
|
|
1421
1476
|
quarantineDevice(serial, `did not take the current build — ${reason}`);
|
|
1422
1477
|
evictHoldersOf(serial, `${serial} left the pool without the current build`);
|
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.
|
|
6
|
+
exports.VERSION = '0.29.0';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "verikun",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.0",
|
|
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",
|