@tapflowio/ios-agent 0.27.0 → 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.
- package/dist/DeviceChromeLoader.d.ts +29 -1
- package/dist/DeviceChromeLoader.d.ts.map +1 -1
- package/dist/DeviceChromeLoader.js +134 -73
- package/dist/DeviceChromeLoader.js.map +1 -1
- package/dist/IOSAgent.d.ts +31 -0
- package/dist/IOSAgent.d.ts.map +1 -1
- package/dist/IOSAgent.js +131 -29
- package/dist/IOSAgent.js.map +1 -1
- package/dist/MjpegStreamer.d.ts.map +1 -1
- package/dist/MjpegStreamer.js +6 -0
- package/dist/MjpegStreamer.js.map +1 -1
- package/package.json +5 -5
package/dist/IOSAgent.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"IOSAgent.d.ts","sourceRoot":"","sources":["../src/IOSAgent.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAA4C,MAAM,EAAE,WAAW,EAAE,wBAAwB,EAAE,mBAAmB,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAA;AAsCpK,eAAO,MAAM,kBAAkB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAIrD,CAAA;AACD,OAAO,
|
|
1
|
+
{"version":3,"file":"IOSAgent.d.ts","sourceRoot":"","sources":["../src/IOSAgent.ts"],"names":[],"mappings":"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"}
|
package/dist/IOSAgent.js
CHANGED
|
@@ -3,7 +3,7 @@ import fs from 'fs';
|
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import { tmpdir } from 'os';
|
|
5
5
|
import { randomUUID } from 'crypto';
|
|
6
|
-
import {
|
|
6
|
+
import { spawn } from 'child_process';
|
|
7
7
|
import { WebSocket } from 'ws';
|
|
8
8
|
import { createLogger, PlatformError, ValidationError, bootAbandonMessage, BOOT_NO_SESSION_STATE, SHUTDOWN_NO_SESSION_STATE, downloadBuild } from '@tapflowio/agent-core';
|
|
9
9
|
const logger = createLogger('ios-agent');
|
|
@@ -39,7 +39,7 @@ export const IOS_BUTTON_ALIASES = {
|
|
|
39
39
|
volume_up: 'volume-up',
|
|
40
40
|
volume_down: 'volume-down',
|
|
41
41
|
};
|
|
42
|
-
import { createResourceSampler, registerStreamWs, disableNagle, createKeyframeAwareSender, pickMaxSize, createRateLimitedDropWarn, createThroughputSampler, createSleepBlocker, getMachineId, isLocalhostWss, DEFAULT_BACKPRESSURE_BYTES, writeEnvelopeHeader, rewriteLowLatencySpsInFrame, CODEC_JPEG, CODEC_H264, CODEC_AUDIO, sendAudioYieldingToVideo, } from '@tapflowio/agent-core/utils';
|
|
42
|
+
import { createResourceSampler, registerStreamWs, disableNagle, createKeyframeAwareSender, pickMaxSize, createRateLimitedDropWarn, createThroughputSampler, createSleepBlocker, acquireLoopStallWatch, describeRelayLoss, getMachineId, isLocalhostWss, DEFAULT_BACKPRESSURE_BYTES, writeEnvelopeHeader, rewriteLowLatencySpsInFrame, CODEC_JPEG, CODEC_H264, CODEC_AUDIO, sendAudioYieldingToVideo, } from '@tapflowio/agent-core/utils';
|
|
43
43
|
import { SimctlWrapper, isDeviceMissingError, ClipboardTooLargeError, firstLine, launchFailureReason } from './SimctlWrapper.js';
|
|
44
44
|
import { SimulatorNetwork } from './SimulatorNetwork.js';
|
|
45
45
|
import { LeanStore } from './LeanStore.js';
|
|
@@ -58,8 +58,8 @@ import { KEY_CODE_MAP, MODIFIER_BITS } from './KeyCodeMap.js';
|
|
|
58
58
|
// processes (launched apps, WebKit WebContent). Short enough that a tab's audio starts promptly,
|
|
59
59
|
// long enough to keep `ps` overhead negligible.
|
|
60
60
|
const AUDIO_POLL_MS = 1500;
|
|
61
|
-
|
|
62
|
-
const
|
|
61
|
+
/** Bound on `simctl list` during a reconnect attempt only; see `connectOnce`. */
|
|
62
|
+
const RECONNECT_LIST_TIMEOUT_MS = 10_000;
|
|
63
63
|
/** Lean mode needs an iOS runtime of 18.5 or later, where launchd honours the host-side overrides
|
|
64
64
|
* across reboots. tvOS, watchOS and visionOS devices come through the same list and are left alone. */
|
|
65
65
|
export function leanSupported(osVersion) {
|
|
@@ -146,6 +146,10 @@ export class IOSAgent {
|
|
|
146
146
|
// Holds a macOS power assertion while connected so the host doesn't idle-throttle the
|
|
147
147
|
// simulator capture/encode when the Mac is unattended. No-op off macOS.
|
|
148
148
|
sleepBlocker;
|
|
149
|
+
/** What the next "relay disconnected" line reports: when the relay last pinged, and the socket's last error. */
|
|
150
|
+
lastPingAt = null;
|
|
151
|
+
lastSocketError;
|
|
152
|
+
releaseStallWatch = null;
|
|
149
153
|
relayUrl = null;
|
|
150
154
|
/** The last device-type lookup that answered. A udid's type never changes, so on a reconnect whose
|
|
151
155
|
* lookup fails this is still right — and the relay overwrites device fields on every register, so
|
|
@@ -188,7 +192,9 @@ export class IOSAgent {
|
|
|
188
192
|
this.fps = options.fps ?? 30;
|
|
189
193
|
this.intervalMs = options.intervalMs;
|
|
190
194
|
this.reconnectDelays = options.reconnectDelays ?? [1000, 2000, 4000, 8000, 16000, 30000];
|
|
191
|
-
this.chromeLoader = new DeviceChromeLoader(
|
|
195
|
+
this.chromeLoader = options.chromeLoader ?? new DeviceChromeLoader(process.env.VITEST
|
|
196
|
+
? { run: () => Promise.reject(new Error('no chrome under vitest')) }
|
|
197
|
+
: {});
|
|
192
198
|
this.deviceFilter = options.deviceFilter;
|
|
193
199
|
this.token = options.token;
|
|
194
200
|
this.handshakeTimeoutMs = options.handshakeTimeoutMs ?? 10_000;
|
|
@@ -200,6 +206,15 @@ export class IOSAgent {
|
|
|
200
206
|
return first?.sessionId ?? null;
|
|
201
207
|
}
|
|
202
208
|
async connect(relayUrl) {
|
|
209
|
+
return this.connectOnce(relayUrl);
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* `listTimeoutMs` is set only by the reconnect loop. `simctl list` is left unbounded elsewhere on purpose
|
|
213
|
+
* (see `SimctlWrapper.listDevices`), but here a hang has a worse outcome than a failure: the attempt never
|
|
214
|
+
* settles, `_scheduleReconnect` is never reached, and the agent stays offline with nothing in its output
|
|
215
|
+
* even after CoreSimulator recovers. A bounded attempt fails and the loop tries again.
|
|
216
|
+
*/
|
|
217
|
+
async connectOnce(relayUrl, listTimeoutMs) {
|
|
203
218
|
this._stopping = false;
|
|
204
219
|
// Paired with the `dispose()` in `disconnect()`. This method is public and reuses the network, so
|
|
205
220
|
// without it a reconnect leaves the liveness watcher off for good — see `SimulatorNetwork.resume`.
|
|
@@ -209,7 +224,7 @@ export class IOSAgent {
|
|
|
209
224
|
this._reconnectTimer = null;
|
|
210
225
|
}
|
|
211
226
|
this.relayUrl = relayUrl;
|
|
212
|
-
const allDevices = await this.simctl.listDevices();
|
|
227
|
+
const allDevices = await this.simctl.listDevices(listTimeoutMs);
|
|
213
228
|
this.revertLeftoverLean(allDevices);
|
|
214
229
|
const devices = this.deviceFilter
|
|
215
230
|
? allDevices.filter((d) => d.name === this.deviceFilter || d.id === this.deviceFilter)
|
|
@@ -285,7 +300,18 @@ export class IOSAgent {
|
|
|
285
300
|
});
|
|
286
301
|
this.reportResources();
|
|
287
302
|
this.resourcesTimer = setInterval(() => this.reportResources(), 5000);
|
|
288
|
-
|
|
303
|
+
this.lastPingAt = null;
|
|
304
|
+
this.lastSocketError = undefined;
|
|
305
|
+
ws.on('ping', () => { this.lastPingAt = Date.now(); });
|
|
306
|
+
// After registration the handshake's `once('error', reject)` lands on a settled promise, so the
|
|
307
|
+
// code (ETIMEDOUT, ECONNRESET…) was dropped. It is the network half of the disconnect line.
|
|
308
|
+
ws.on('error', (e) => { this.lastSocketError = e.code ?? e.message; });
|
|
309
|
+
// `now` is taken here, at the close, not when the warning prints: the cleanup in between can run
|
|
310
|
+
// work that holds the event loop and would inflate the ping age.
|
|
311
|
+
ws.on('close', (code, reason) => this._scheduleReconnect({
|
|
312
|
+
code, reason: reason.toString(), errorCode: this.lastSocketError, lastPingAt: this.lastPingAt, now: Date.now(),
|
|
313
|
+
}));
|
|
314
|
+
this.releaseStallWatch ??= acquireLoopStallWatch();
|
|
289
315
|
resolve();
|
|
290
316
|
}
|
|
291
317
|
else {
|
|
@@ -358,12 +384,15 @@ export class IOSAgent {
|
|
|
358
384
|
// other things that tick. It is `unref`'d, which is why nobody noticed it had no caller.
|
|
359
385
|
this.network.dispose();
|
|
360
386
|
this.sleepBlocker.release();
|
|
387
|
+
this.releaseStallWatch?.();
|
|
388
|
+
this.releaseStallWatch = null;
|
|
361
389
|
this.ws?.close();
|
|
362
390
|
this.ws = null;
|
|
363
391
|
this.relayUrl = null;
|
|
364
392
|
this.simctl.stopKeyboardDaemon();
|
|
365
393
|
}
|
|
366
|
-
|
|
394
|
+
/** `loss` is absent when a reconnect attempt failed: there is no close to describe, only a retry. */
|
|
395
|
+
_scheduleReconnect(loss) {
|
|
367
396
|
if (this._stopping)
|
|
368
397
|
return;
|
|
369
398
|
if (this.resourcesTimer) {
|
|
@@ -385,12 +414,14 @@ export class IOSAgent {
|
|
|
385
414
|
const delays = this.reconnectDelays;
|
|
386
415
|
const delay = delays[Math.min(this._reconnectAttempt, delays.length - 1)];
|
|
387
416
|
this._reconnectAttempt++;
|
|
388
|
-
logger.warn(
|
|
417
|
+
logger.warn(loss
|
|
418
|
+
? `relay disconnected (${describeRelayLoss(loss)}) — reconnecting in ${delay / 1000}s (attempt ${this._reconnectAttempt})`
|
|
419
|
+
: `reconnecting in ${delay / 1000}s (attempt ${this._reconnectAttempt})`);
|
|
389
420
|
this._reconnectTimer = setTimeout(() => {
|
|
390
421
|
this._reconnectTimer = null;
|
|
391
422
|
if (this._stopping || !this.relayUrl)
|
|
392
423
|
return;
|
|
393
|
-
this.
|
|
424
|
+
this.connectOnce(this.relayUrl, RECONNECT_LIST_TIMEOUT_MS).then(() => {
|
|
394
425
|
this._reconnectAttempt = 0;
|
|
395
426
|
logger.info('reconnected to relay');
|
|
396
427
|
}).catch((e) => {
|
|
@@ -426,7 +457,7 @@ export class IOSAgent {
|
|
|
426
457
|
void this.network.forget(state.deviceId).catch((e) => {
|
|
427
458
|
logger.warn('could not clear the network rule for a retired device:', e.message);
|
|
428
459
|
});
|
|
429
|
-
|
|
460
|
+
state.streamReader?.cancel().catch(() => { });
|
|
430
461
|
state.streamReader = null;
|
|
431
462
|
state.captureStreamer = null; // reader.cancel() kills the helper proc; drop the ref so a stale requestKeyframe() no-ops
|
|
432
463
|
if (state.audioPoll) {
|
|
@@ -443,12 +474,15 @@ export class IOSAgent {
|
|
|
443
474
|
state.streamWs?.close();
|
|
444
475
|
state.streamWs = null;
|
|
445
476
|
}
|
|
446
|
-
|
|
477
|
+
/** Resolves `false` when a newer boot or a relay loss retired this one during the chrome load, in which case
|
|
478
|
+
* nothing was stored and the caller abandons the boot. A closed socket alone continues as it always did —
|
|
479
|
+
* the relay loss that follows bumps the seq. */
|
|
480
|
+
async sendChromeData(state, device, seq) {
|
|
447
481
|
// `readyState`, not presence: this runs mid-boot, and a socket that closed since the entry guard
|
|
448
482
|
// takes the payload into a buffer nobody flushes while `device:ready` is dropped by `sendMsg`'s own
|
|
449
483
|
// check — leaving the caller with neither the data nor an answer.
|
|
450
484
|
if (this.ws?.readyState !== WebSocket.OPEN)
|
|
451
|
-
return;
|
|
485
|
+
return true;
|
|
452
486
|
// Stop the outgoing helper before dropping the reference. This used to leak a child process;
|
|
453
487
|
// now that a helper revives itself on death, an orphan would also keep respawning with
|
|
454
488
|
// nobody left holding a reference to stop it.
|
|
@@ -463,16 +497,28 @@ export class IOSAgent {
|
|
|
463
497
|
osVersion: device.osVersion ?? '',
|
|
464
498
|
},
|
|
465
499
|
});
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
this.
|
|
500
|
+
// Awaited where it used to block: a cold load is seconds of swift renders. A newer boot or a shutdown can
|
|
501
|
+
// land meanwhile, so the result is stored only after the seq is checked — otherwise a slow stale load
|
|
502
|
+
// would overwrite the chrome a faster newer boot already set, and the button path reads it.
|
|
503
|
+
const chrome = await this.chromeLoader.load(device.typeId ?? device.name);
|
|
504
|
+
if (seq !== state.bootSeq)
|
|
505
|
+
return false;
|
|
506
|
+
const ws = this.ws;
|
|
507
|
+
if (ws?.readyState !== WebSocket.OPEN)
|
|
508
|
+
return true;
|
|
509
|
+
state.loadedChrome = chrome;
|
|
510
|
+
if (!chrome)
|
|
511
|
+
return true;
|
|
512
|
+
this.sendOn(ws, {
|
|
470
513
|
type: 'session:chrome',
|
|
471
514
|
sessionId: state.sessionId,
|
|
472
|
-
payload:
|
|
515
|
+
payload: chrome,
|
|
473
516
|
});
|
|
517
|
+
return true;
|
|
474
518
|
}
|
|
475
|
-
|
|
519
|
+
/** `failures` counts the frameless runs before this one; only the restart in `pump` passes it, so a new
|
|
520
|
+
* boot starts from zero without any state to reset. */
|
|
521
|
+
startBinaryStream(state, streamWs, failures = 0) {
|
|
476
522
|
// H.264 is the default; opt out per-agent with TAPFLOW_IOS_CODEC=jpeg. It also needs
|
|
477
523
|
// a browser that reported it can decode it (device:boot acceptH264) — otherwise JPEG.
|
|
478
524
|
// Only on the ScreenCaptureStreamer path — the MjpegStreamer fallback is always JPEG, and now
|
|
@@ -520,11 +566,13 @@ export class IOSAgent {
|
|
|
520
566
|
state.captureStreamer?.requestKeyframe();
|
|
521
567
|
} };
|
|
522
568
|
const pump = async () => {
|
|
569
|
+
let delivered = false;
|
|
523
570
|
try {
|
|
524
571
|
while (true) {
|
|
525
572
|
const { value, done } = await reader.read();
|
|
526
573
|
if (done)
|
|
527
574
|
break;
|
|
575
|
+
delivered = true;
|
|
528
576
|
// Declare reorder=0 on the keyframe SPS so every decoder (WebCodecs, MSE,
|
|
529
577
|
// WASM) emits frames immediately instead of buffering the level's max DPB
|
|
530
578
|
// (~8 frames ≈ 250ms). Keyframe-only (SPS lives there); no-op otherwise.
|
|
@@ -543,7 +591,26 @@ export class IOSAgent {
|
|
|
543
591
|
if (metricsTimer)
|
|
544
592
|
clearInterval(metricsTimer);
|
|
545
593
|
if (state.streamReader === reader && streamWs.readyState === WebSocket.OPEN) {
|
|
546
|
-
|
|
594
|
+
const next = streamRestartPlan(failures, delivered);
|
|
595
|
+
if (next.failures === 2) {
|
|
596
|
+
logger.warn(`screen stream for ${state.deviceId} keeps ending before its first frame — backing off restarts`);
|
|
597
|
+
}
|
|
598
|
+
// Re-checked after the delay: a new boot, a shutdown, a relay loss or disconnect() all swap or clear
|
|
599
|
+
// the reader, or close the socket, in the meantime.
|
|
600
|
+
const restart = () => {
|
|
601
|
+
if (this._stopping || state.streamReader !== reader || streamWs.readyState !== WebSocket.OPEN)
|
|
602
|
+
return;
|
|
603
|
+
try {
|
|
604
|
+
this.startBinaryStream(state, streamWs, next.failures);
|
|
605
|
+
}
|
|
606
|
+
catch (e) {
|
|
607
|
+
logger.error(`screen stream for ${state.deviceId} could not restart: ${e instanceof Error ? e.message : String(e)}`);
|
|
608
|
+
}
|
|
609
|
+
};
|
|
610
|
+
if (next.delayMs === 0)
|
|
611
|
+
restart();
|
|
612
|
+
else
|
|
613
|
+
setTimeout(restart, next.delayMs).unref();
|
|
547
614
|
}
|
|
548
615
|
};
|
|
549
616
|
void pump();
|
|
@@ -631,7 +698,7 @@ export class IOSAgent {
|
|
|
631
698
|
// superseded was gone as well. The caller then waited out the very deadline this change exists to
|
|
632
699
|
// end. `finally` is what stops `bootAbandon` outliving the boot that would have read it.
|
|
633
700
|
try {
|
|
634
|
-
|
|
701
|
+
state.streamReader?.cancel().catch(() => { });
|
|
635
702
|
state.streamReader = null;
|
|
636
703
|
state.captureStreamer = null; // reader.cancel() kills the helper proc; drop the ref so a stale requestKeyframe() no-ops
|
|
637
704
|
state.touchHelper?.stop();
|
|
@@ -750,7 +817,10 @@ export class IOSAgent {
|
|
|
750
817
|
this.abandonBoot(state, seq, sessionId, requestId);
|
|
751
818
|
return;
|
|
752
819
|
}
|
|
753
|
-
this.sendChromeData(state, bootedDevice)
|
|
820
|
+
if (!(await this.sendChromeData(state, bootedDevice, seq))) {
|
|
821
|
+
this.abandonBoot(state, seq, sessionId, requestId);
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
754
824
|
const streamWs = await this.openStreamWs(state);
|
|
755
825
|
if (seq !== state.bootSeq) {
|
|
756
826
|
streamWs.close();
|
|
@@ -890,7 +960,7 @@ export class IOSAgent {
|
|
|
890
960
|
void this.network.forget(deviceId).catch((e) => {
|
|
891
961
|
logger.warn('could not clear the network rule for a shut-down device:', e.message);
|
|
892
962
|
});
|
|
893
|
-
|
|
963
|
+
state.streamReader?.cancel().catch(() => { });
|
|
894
964
|
state.streamReader = null;
|
|
895
965
|
state.captureStreamer = null; // reader.cancel() kills the helper proc; drop the ref so a stale requestKeyframe() no-ops
|
|
896
966
|
state.touchHelper?.stop();
|
|
@@ -1779,7 +1849,8 @@ export class IOSAgent {
|
|
|
1779
1849
|
return await this.installFrom(udid, source, tmpDir);
|
|
1780
1850
|
}
|
|
1781
1851
|
finally {
|
|
1782
|
-
|
|
1852
|
+
// Async for the same reason as the extraction: a large .app is a lot of files to unlink.
|
|
1853
|
+
await fs.promises.rm(tmpDir, { recursive: true, force: true });
|
|
1783
1854
|
}
|
|
1784
1855
|
}
|
|
1785
1856
|
/** Extraction + `simctl install`, given a file this machine can actually open. */
|
|
@@ -1794,11 +1865,12 @@ export class IOSAgent {
|
|
|
1794
1865
|
// `tmpDir`, and extracting beside it would put the two in one listing.
|
|
1795
1866
|
const outDir = path.join(tmpDir, 'x');
|
|
1796
1867
|
fs.mkdirSync(outDir, { recursive: true });
|
|
1797
|
-
//
|
|
1798
|
-
//
|
|
1868
|
+
// **Not `spawnSync`.** It held the event loop for as long as the extraction ran, which on a large build
|
|
1869
|
+
// or a busy Mac was long enough for the relay's heartbeat to end this agent's connection — and every
|
|
1870
|
+
// other session on the Mac froze with it.
|
|
1799
1871
|
const result = isTar
|
|
1800
|
-
?
|
|
1801
|
-
:
|
|
1872
|
+
? await runExtractor('tar', ['-xzf', filePath, '-C', outDir])
|
|
1873
|
+
: await runExtractor('unzip', ['-q', '-o', filePath, '-d', outDir]);
|
|
1802
1874
|
// 실행 자체 실패(tar/unzip 부재=ENOENT 등)는 아카이브 무효와 구분한다.
|
|
1803
1875
|
if (result.error) {
|
|
1804
1876
|
const code = result.error.code ?? result.error.message;
|
|
@@ -1809,7 +1881,7 @@ export class IOSAgent {
|
|
|
1809
1881
|
// evidence away, so a build that was fine read as corrupt — `unzip` had said
|
|
1810
1882
|
// `cannot find or open`, which names a missing file rather than a bad archive, and nobody
|
|
1811
1883
|
// could see it. Trimmed because the whole thing reaches a browser toast.
|
|
1812
|
-
const detail =
|
|
1884
|
+
const detail = result.stderr.trim().split('\n')[0]?.slice(0, 300);
|
|
1813
1885
|
throw new ValidationError((isTar
|
|
1814
1886
|
? 'tar.gz 압축 해제 실패 — 시뮬레이터용 .tar.gz(경로 탈출/심볼릭 링크 없는)인지 확인하세요.'
|
|
1815
1887
|
: 'zip 압축 해제 실패 — 시뮬레이터용 .app.zip 파일인지 확인하세요.')
|
|
@@ -2157,4 +2229,34 @@ export class IOSAgent {
|
|
|
2157
2229
|
return this.simctl.openUrl(await this.soleLiveDeviceId(), url);
|
|
2158
2230
|
}
|
|
2159
2231
|
}
|
|
2232
|
+
/**
|
|
2233
|
+
* Runs an archive tool without blocking, in the shape `spawnSync` returned: a spawn failure (the tool is
|
|
2234
|
+
* missing) in `error`, otherwise the exit `status` and what it wrote to stderr. stdout is discarded — the
|
|
2235
|
+
* tools run quiet, and nothing reads it.
|
|
2236
|
+
*/
|
|
2237
|
+
function runExtractor(cmd, args) {
|
|
2238
|
+
return new Promise((resolve) => {
|
|
2239
|
+
const child = spawn(cmd, args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
2240
|
+
let stderr = '';
|
|
2241
|
+
// Decoded by the stream, not per chunk: a chunk boundary inside a multibyte character (a Korean file
|
|
2242
|
+
// name in unzip's message) would otherwise turn into replacement characters.
|
|
2243
|
+
child.stderr?.setEncoding('utf8');
|
|
2244
|
+
child.stderr?.on('data', (chunk) => { if (stderr.length < 64 * 1024)
|
|
2245
|
+
stderr += chunk; });
|
|
2246
|
+
child.once('error', (error) => resolve({ error, status: null, stderr }));
|
|
2247
|
+
child.once('close', (status) => resolve({ status, stderr }));
|
|
2248
|
+
});
|
|
2249
|
+
}
|
|
2250
|
+
/**
|
|
2251
|
+
* When to restart a screen stream that just ended. Only a run that delivered a frame resets the count — not
|
|
2252
|
+
* how long it lasted: the helper retries `device.io` and its ports for up to ~10s before it gives up, so a
|
|
2253
|
+
* duration rule would call every first-boot failure healthy. A simulator shut down from outside makes the
|
|
2254
|
+
* helper exit at once, which without this was a restart loop at full speed.
|
|
2255
|
+
*/
|
|
2256
|
+
export function streamRestartPlan(previousFailures, deliveredFrame) {
|
|
2257
|
+
if (deliveredFrame)
|
|
2258
|
+
return { failures: 0, delayMs: 0 };
|
|
2259
|
+
const failures = previousFailures + 1;
|
|
2260
|
+
return { failures, delayMs: failures <= 1 ? 0 : Math.min(250 * 2 ** (failures - 2), 5000) };
|
|
2261
|
+
}
|
|
2160
2262
|
//# sourceMappingURL=IOSAgent.js.map
|