@tapflowio/ios-agent 0.26.1 → 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.
@@ -4,6 +4,7 @@ import { type SleepBlocker } from '@tapflowio/agent-core/utils';
4
4
  import { SimctlWrapper } from './SimctlWrapper.js';
5
5
  import { SimulatorNetwork } from './SimulatorNetwork.js';
6
6
  import { LeanStore } from './LeanStore.js';
7
+ import { DeviceChromeLoader } from './DeviceChromeLoader.js';
7
8
  /** Lean mode needs an iOS runtime of 18.5 or later, where launchd honours the host-side overrides
8
9
  * across reboots. tvOS, watchOS and visionOS devices come through the same list and are left alone. */
9
10
  export declare function leanSupported(osVersion: string | undefined): boolean;
@@ -27,6 +28,9 @@ export interface IOSAgentOptions {
27
28
  lean?: boolean;
28
29
  /** Where the launchd overrides are written. Injectable for the same reason as `network`. */
29
30
  leanStore?: LeanStore;
31
+ /** Device frame loading. Under vitest the default points at directories that do not exist, so a suite never
32
+ * reads the developer's Xcode chrome or spends seconds rendering it. */
33
+ chromeLoader?: DeviceChromeLoader;
30
34
  }
31
35
  export declare class IOSAgent implements DeviceAgent, NetworkControlCapability {
32
36
  private readonly simctl;
@@ -62,6 +66,14 @@ export declare class IOSAgent implements DeviceAgent, NetworkControlCapability {
62
66
  * inside `onopen` on a local socket. */
63
67
  private sendOn;
64
68
  private deviceStates;
69
+ /**
70
+ * One queue of rotations per device (#910). Each is its own helper process, so two sent back to back
71
+ * can land in either order, and a held ⌘⇧O or the boot reset racing the first press would leave the
72
+ * simulator on the earlier target. Keyed here rather than on `DeviceState` because a re-register
73
+ * replaces the states while a rotation already in flight keeps running — the boot reset that follows
74
+ * has to queue behind it, not start fresh beside it.
75
+ */
76
+ private readonly rotations;
65
77
  /**
66
78
  * Devices **this agent booted**, by device id.
67
79
  *
@@ -86,7 +98,15 @@ export declare class IOSAgent implements DeviceAgent, NetworkControlCapability {
86
98
  private lastBundleIds;
87
99
  private readonly uiTreeReader;
88
100
  private readonly sleepBlocker;
101
+ /** What the next "relay disconnected" line reports: when the relay last pinged, and the socket's last error. */
102
+ private lastPingAt;
103
+ private lastSocketError;
104
+ private releaseStallWatch;
89
105
  private relayUrl;
106
+ /** The last device-type lookup that answered. A udid's type never changes, so on a reconnect whose
107
+ * lookup fails this is still right — and the relay overwrites device fields on every register, so
108
+ * falling back to nothing would blank a form factor it already had until the next reconnect. */
109
+ private formFactors;
90
110
  private resourcesTimer;
91
111
  private readonly resources;
92
112
  private _stopping;
@@ -97,12 +117,25 @@ export declare class IOSAgent implements DeviceAgent, NetworkControlCapability {
97
117
  constructor(options?: IOSAgentOptions, simctl?: SimctlWrapper);
98
118
  get sessionId(): string | null;
99
119
  connect(relayUrl: string): Promise<void>;
120
+ /**
121
+ * `listTimeoutMs` is set only by the reconnect loop. `simctl list` is left unbounded elsewhere on purpose
122
+ * (see `SimctlWrapper.listDevices`), but here a hang has a worse outcome than a failure: the attempt never
123
+ * settles, `_scheduleReconnect` is never reached, and the agent stays offline with nothing in its output
124
+ * even after CoreSimulator recovers. A bounded attempt fails and the loop tries again.
125
+ */
126
+ private connectOnce;
100
127
  private initDeviceStates;
101
128
  disconnect(): void;
129
+ /** `loss` is absent when a reconnect attempt failed: there is no close to describe, only a retry. */
102
130
  private _scheduleReconnect;
103
131
  private reportResources;
104
132
  private cleanupDeviceState;
133
+ /** Resolves `false` when a newer boot or a relay loss retired this one during the chrome load, in which case
134
+ * nothing was stored and the caller abandons the boot. A closed socket alone continues as it always did —
135
+ * the relay loss that follows bumps the seq. */
105
136
  private sendChromeData;
137
+ /** `failures` counts the frameless runs before this one; only the restart in `pump` passes it, so a new
138
+ * boot starts from zero without any state to reset. */
106
139
  private startBinaryStream;
107
140
  private wsClientOptions;
108
141
  private openStreamWs;
@@ -176,6 +209,9 @@ export declare class IOSAgent implements DeviceAgent, NetworkControlCapability {
176
209
  private refusalReason;
177
210
  private ackInput;
178
211
  private isBooted;
212
+ /** Run `step` after every rotation already queued for this device. A failing step is logged and does
213
+ * not stop the ones behind it. */
214
+ private enqueueRotation;
179
215
  private handleRelayMessage;
180
216
  private readUITree;
181
217
  /**
@@ -318,4 +354,14 @@ export declare class IOSAgent implements DeviceAgent, NetworkControlCapability {
318
354
  touchEnd(): Promise<void>;
319
355
  openUrl(url: string): Promise<void>;
320
356
  }
357
+ /**
358
+ * When to restart a screen stream that just ended. Only a run that delivered a frame resets the count — not
359
+ * how long it lasted: the helper retries `device.io` and its ports for up to ~10s before it gives up, so a
360
+ * duration rule would call every first-boot failure healthy. A simulator shut down from outside makes the
361
+ * helper exit at once, which without this was a restart loop at full speed.
362
+ */
363
+ export declare function streamRestartPlan(previousFailures: number, deliveredFrame: boolean): {
364
+ failures: number;
365
+ delayMs: number;
366
+ };
321
367
  //# sourceMappingURL=IOSAgent.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"IOSAgent.d.ts","sourceRoot":"","sources":["../src/IOSAgent.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"IOSAgent.d.ts","sourceRoot":"","sources":["../src/IOSAgent.ts"],"names":[],"mappings":"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"}
package/dist/IOSAgent.js CHANGED
@@ -3,9 +3,9 @@ import fs from 'fs';
3
3
  import path from 'path';
4
4
  import { tmpdir } from 'os';
5
5
  import { randomUUID } from 'crypto';
6
- import { spawnSync } from 'child_process';
6
+ import { spawn } from 'child_process';
7
7
  import { WebSocket } from 'ws';
8
- import { createLogger, PlatformError, ValidationError, bootAbandonMessage, BOOT_NO_SESSION_STATE, downloadBuild } from '@tapflowio/agent-core';
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');
10
10
  // Typed so a typo cannot ship silently — the viewer gates the whole clipboard bridge on this.
11
11
  // `full-reset` is honoured in `handleDeviceBoot`, which shuts a running device down before
@@ -39,8 +39,8 @@ 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';
43
- import { SimctlWrapper, isDeviceMissingError, ClipboardTooLargeError, firstLine } from './SimctlWrapper.js';
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
+ import { SimctlWrapper, isDeviceMissingError, ClipboardTooLargeError, firstLine, launchFailureReason } from './SimctlWrapper.js';
44
44
  import { SimulatorNetwork } from './SimulatorNetwork.js';
45
45
  import { LeanStore } from './LeanStore.js';
46
46
  import { LEAN_LABELS } from './leanLabels.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
- // 아카이브 추출(tar/unzip) 시 stdout 상한. 기본 1MB 로는 파일 많은 큰 .app 에서 넘칠 수 있어 넉넉히 잡는다.
62
- const EXTRACT_MAXBUFFER = 256 * 1024 * 1024; // 256 MB
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) {
@@ -109,6 +109,14 @@ export class IOSAgent {
109
109
  ws.send(JSON.stringify(msg));
110
110
  }
111
111
  deviceStates = new Map();
112
+ /**
113
+ * One queue of rotations per device (#910). Each is its own helper process, so two sent back to back
114
+ * can land in either order, and a held ⌘⇧O or the boot reset racing the first press would leave the
115
+ * simulator on the earlier target. Keyed here rather than on `DeviceState` because a re-register
116
+ * replaces the states while a rotation already in flight keeps running — the boot reset that follows
117
+ * has to queue behind it, not start fresh beside it.
118
+ */
119
+ rotations = new Map();
112
120
  /**
113
121
  * Devices **this agent booted**, by device id.
114
122
  *
@@ -138,7 +146,15 @@ export class IOSAgent {
138
146
  // Holds a macOS power assertion while connected so the host doesn't idle-throttle the
139
147
  // simulator capture/encode when the Mac is unattended. No-op off macOS.
140
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;
141
153
  relayUrl = null;
154
+ /** The last device-type lookup that answered. A udid's type never changes, so on a reconnect whose
155
+ * lookup fails this is still right — and the relay overwrites device fields on every register, so
156
+ * falling back to nothing would blank a form factor it already had until the next reconnect. */
157
+ formFactors = new Map();
142
158
  resourcesTimer = null;
143
159
  resources = createResourceSampler();
144
160
  _stopping = false;
@@ -176,7 +192,9 @@ export class IOSAgent {
176
192
  this.fps = options.fps ?? 30;
177
193
  this.intervalMs = options.intervalMs;
178
194
  this.reconnectDelays = options.reconnectDelays ?? [1000, 2000, 4000, 8000, 16000, 30000];
179
- 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
+ : {});
180
198
  this.deviceFilter = options.deviceFilter;
181
199
  this.token = options.token;
182
200
  this.handshakeTimeoutMs = options.handshakeTimeoutMs ?? 10_000;
@@ -188,6 +206,15 @@ export class IOSAgent {
188
206
  return first?.sessionId ?? null;
189
207
  }
190
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) {
191
218
  this._stopping = false;
192
219
  // Paired with the `dispose()` in `disconnect()`. This method is public and reuses the network, so
193
220
  // without it a reconnect leaves the liveness watcher off for good — see `SimulatorNetwork.resume`.
@@ -197,11 +224,21 @@ export class IOSAgent {
197
224
  this._reconnectTimer = null;
198
225
  }
199
226
  this.relayUrl = relayUrl;
200
- const allDevices = await this.simctl.listDevices();
227
+ const allDevices = await this.simctl.listDevices(listTimeoutMs);
201
228
  this.revertLeftoverLean(allDevices);
202
229
  const devices = this.deviceFilter
203
230
  ? allDevices.filter((d) => d.name === this.deviceFilter || d.id === this.deviceFilter)
204
231
  : allDevices;
232
+ // A `disconnect()` during this await or the one above does not cancel the connect. Every caller
233
+ // today exits the process on the next line (the CLI's SIGINT handlers); one that keeps running
234
+ // needs a generation check here before the socket is built.
235
+ // An answer the lookup cannot give costs the field, not the register: a viewer draws a device with
236
+ // no form factor as a phone, which is what it did before there was one. A reconnect falls back to
237
+ // the last answer rather than to none (see `formFactors`).
238
+ const formFactors = await this.simctl.formFactorsByType().then((map) => (this.formFactors = map), (e) => {
239
+ logger.warn(`device types unavailable, registering with the last known form factors: ${e instanceof Error ? e.message : String(e)}`);
240
+ return this.formFactors;
241
+ });
205
242
  return new Promise((resolve, reject) => {
206
243
  const ws = new WebSocket(relayUrl, this.wsClientOptions());
207
244
  let registered = false;
@@ -226,6 +263,7 @@ export class IOSAgent {
226
263
  platform: d.platform,
227
264
  status: d.status,
228
265
  osVersion: d.osVersion,
266
+ formFactor: d.typeId ? formFactors.get(d.typeId) : undefined,
229
267
  })),
230
268
  });
231
269
  });
@@ -262,7 +300,18 @@ export class IOSAgent {
262
300
  });
263
301
  this.reportResources();
264
302
  this.resourcesTimer = setInterval(() => this.reportResources(), 5000);
265
- ws.on('close', () => this._scheduleReconnect());
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();
266
315
  resolve();
267
316
  }
268
317
  else {
@@ -335,12 +384,15 @@ export class IOSAgent {
335
384
  // other things that tick. It is `unref`'d, which is why nobody noticed it had no caller.
336
385
  this.network.dispose();
337
386
  this.sleepBlocker.release();
387
+ this.releaseStallWatch?.();
388
+ this.releaseStallWatch = null;
338
389
  this.ws?.close();
339
390
  this.ws = null;
340
391
  this.relayUrl = null;
341
392
  this.simctl.stopKeyboardDaemon();
342
393
  }
343
- _scheduleReconnect() {
394
+ /** `loss` is absent when a reconnect attempt failed: there is no close to describe, only a retry. */
395
+ _scheduleReconnect(loss) {
344
396
  if (this._stopping)
345
397
  return;
346
398
  if (this.resourcesTimer) {
@@ -362,12 +414,14 @@ export class IOSAgent {
362
414
  const delays = this.reconnectDelays;
363
415
  const delay = delays[Math.min(this._reconnectAttempt, delays.length - 1)];
364
416
  this._reconnectAttempt++;
365
- logger.warn(`relay disconnected — reconnecting in ${delay / 1000}s (attempt ${this._reconnectAttempt})`);
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})`);
366
420
  this._reconnectTimer = setTimeout(() => {
367
421
  this._reconnectTimer = null;
368
422
  if (this._stopping || !this.relayUrl)
369
423
  return;
370
- this.connect(this.relayUrl).then(() => {
424
+ this.connectOnce(this.relayUrl, RECONNECT_LIST_TIMEOUT_MS).then(() => {
371
425
  this._reconnectAttempt = 0;
372
426
  logger.info('reconnected to relay');
373
427
  }).catch((e) => {
@@ -403,7 +457,7 @@ export class IOSAgent {
403
457
  void this.network.forget(state.deviceId).catch((e) => {
404
458
  logger.warn('could not clear the network rule for a retired device:', e.message);
405
459
  });
406
- void state.streamReader?.cancel();
460
+ state.streamReader?.cancel().catch(() => { });
407
461
  state.streamReader = null;
408
462
  state.captureStreamer = null; // reader.cancel() kills the helper proc; drop the ref so a stale requestKeyframe() no-ops
409
463
  if (state.audioPoll) {
@@ -420,12 +474,15 @@ export class IOSAgent {
420
474
  state.streamWs?.close();
421
475
  state.streamWs = null;
422
476
  }
423
- sendChromeData(state, device) {
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) {
424
481
  // `readyState`, not presence: this runs mid-boot, and a socket that closed since the entry guard
425
482
  // takes the payload into a buffer nobody flushes while `device:ready` is dropped by `sendMsg`'s own
426
483
  // check — leaving the caller with neither the data nor an answer.
427
484
  if (this.ws?.readyState !== WebSocket.OPEN)
428
- return;
485
+ return true;
429
486
  // Stop the outgoing helper before dropping the reference. This used to leak a child process;
430
487
  // now that a helper revives itself on death, an orphan would also keep respawning with
431
488
  // nobody left holding a reference to stop it.
@@ -440,16 +497,28 @@ export class IOSAgent {
440
497
  osVersion: device.osVersion ?? '',
441
498
  },
442
499
  });
443
- state.loadedChrome = this.chromeLoader.load(device.typeId ?? device.name);
444
- if (!state.loadedChrome)
445
- return;
446
- this.sendOn(this.ws, {
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, {
447
513
  type: 'session:chrome',
448
514
  sessionId: state.sessionId,
449
- payload: state.loadedChrome,
515
+ payload: chrome,
450
516
  });
517
+ return true;
451
518
  }
452
- startBinaryStream(state, streamWs) {
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) {
453
522
  // H.264 is the default; opt out per-agent with TAPFLOW_IOS_CODEC=jpeg. It also needs
454
523
  // a browser that reported it can decode it (device:boot acceptH264) — otherwise JPEG.
455
524
  // Only on the ScreenCaptureStreamer path — the MjpegStreamer fallback is always JPEG, and now
@@ -497,11 +566,13 @@ export class IOSAgent {
497
566
  state.captureStreamer?.requestKeyframe();
498
567
  } };
499
568
  const pump = async () => {
569
+ let delivered = false;
500
570
  try {
501
571
  while (true) {
502
572
  const { value, done } = await reader.read();
503
573
  if (done)
504
574
  break;
575
+ delivered = true;
505
576
  // Declare reorder=0 on the keyframe SPS so every decoder (WebCodecs, MSE,
506
577
  // WASM) emits frames immediately instead of buffering the level's max DPB
507
578
  // (~8 frames ≈ 250ms). Keyframe-only (SPS lives there); no-op otherwise.
@@ -520,7 +591,26 @@ export class IOSAgent {
520
591
  if (metricsTimer)
521
592
  clearInterval(metricsTimer);
522
593
  if (state.streamReader === reader && streamWs.readyState === WebSocket.OPEN) {
523
- this.startBinaryStream(state, streamWs);
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();
524
614
  }
525
615
  };
526
616
  void pump();
@@ -608,7 +698,7 @@ export class IOSAgent {
608
698
  // superseded was gone as well. The caller then waited out the very deadline this change exists to
609
699
  // end. `finally` is what stops `bootAbandon` outliving the boot that would have read it.
610
700
  try {
611
- void state.streamReader?.cancel();
701
+ state.streamReader?.cancel().catch(() => { });
612
702
  state.streamReader = null;
613
703
  state.captureStreamer = null; // reader.cancel() kills the helper proc; drop the ref so a stale requestKeyframe() no-ops
614
704
  state.touchHelper?.stop();
@@ -710,6 +800,15 @@ export class IOSAgent {
710
800
  await this.network.arm(deviceId).catch((e) => {
711
801
  logger.warn('could not arm the network injection:', e.message);
712
802
  });
803
+ // **Stand it up (#910).** A fresh viewer always starts portrait, and a simulator left turned —
804
+ // by a session whose undo never arrived, or one an agent restart reset the memory of — would
805
+ // otherwise stream sideways under an upright frame, with every later press inverted. Queued with
806
+ // the rotations, so one still in flight from before a re-register lands first and is overridden.
807
+ // Best-effort: a failure leaves the simulator where it was, and the boot goes on.
808
+ await this.enqueueRotation(deviceId, async () => {
809
+ await this.simctl.rotate(deviceId, 'portrait');
810
+ state.orientation = 'portrait';
811
+ });
713
812
  // Another await — a multi-second one — and `sendChromeData` below starts a helper process. A
714
813
  // shutdown or a newer boot arriving in this gap would otherwise get a helper installed for the
715
814
  // device it is taking down — and one that revives itself, so the stale reference outlives the
@@ -718,7 +817,10 @@ export class IOSAgent {
718
817
  this.abandonBoot(state, seq, sessionId, requestId);
719
818
  return;
720
819
  }
721
- this.sendChromeData(state, bootedDevice);
820
+ if (!(await this.sendChromeData(state, bootedDevice, seq))) {
821
+ this.abandonBoot(state, seq, sessionId, requestId);
822
+ return;
823
+ }
722
824
  const streamWs = await this.openStreamWs(state);
723
825
  if (seq !== state.bootSeq) {
724
826
  streamWs.close();
@@ -838,8 +940,15 @@ export class IOSAgent {
838
940
  }
839
941
  async handleDeviceShutdown(sessionId, deviceId, requestId) {
840
942
  const state = this.deviceStates.get(sessionId);
841
- if (!state)
943
+ if (!state) {
944
+ // Answered only when someone asked: nothing was attempted, and the idle timer's id-less shutdown has no
945
+ // waiter to tell. A failure below is answered either way — `requestId` is optional on `device:shutdown`,
946
+ // so an id-less sender that is waiting can only learn of the failure this way (protocol/AGENTS.md,
947
+ // 「The sender must hold the session」).
948
+ if (requestId)
949
+ this.sendMsg({ type: 'device:shutdown-error', sessionId, requestId, message: SHUTDOWN_NO_SESSION_STATE });
842
950
  return;
951
+ }
843
952
  this.bumpBootSeq(state, 'shut-down');
844
953
  // **Said here as well as in `cleanupDeviceState`, because this path does not go through it.**
845
954
  // The teardown below is a copy of that method's, and the copy is older than the network rule —
@@ -851,7 +960,7 @@ export class IOSAgent {
851
960
  void this.network.forget(deviceId).catch((e) => {
852
961
  logger.warn('could not clear the network rule for a shut-down device:', e.message);
853
962
  });
854
- void state.streamReader?.cancel();
963
+ state.streamReader?.cancel().catch(() => { });
855
964
  state.streamReader = null;
856
965
  state.captureStreamer = null; // reader.cancel() kills the helper proc; drop the ref so a stale requestKeyframe() no-ops
857
966
  state.touchHelper?.stop();
@@ -883,6 +992,15 @@ export class IOSAgent {
883
992
  catch (e) {
884
993
  const message = e instanceof Error ? e.message : String(e);
885
994
  logger.error('shutdown failed:', message);
995
+ // "Already shut down" never reaches here — `SimctlWrapper.shutdown` absorbs it — so this is a device
996
+ // that is still running, with its stream already torn down above. Said, because silence left the
997
+ // caller waiting out its deadline (#455).
998
+ this.sendMsg({
999
+ type: 'device:shutdown-error',
1000
+ sessionId,
1001
+ requestId,
1002
+ message: `The simulator did not shut down, and its stream has been stopped: ${message}`,
1003
+ });
886
1004
  }
887
1005
  }
888
1006
  // Lazily set up the touch channel: a reconnect re-issues the session with touchHelper=null while the sim stays booted, so input self-heals without a fresh device:boot. Sync so a tap's start+end stay paired (async would drop touchEnd → stuck finger).
@@ -1005,6 +1123,17 @@ export class IOSAgent {
1005
1123
  return false;
1006
1124
  }
1007
1125
  }
1126
+ /** Run `step` after every rotation already queued for this device. A failing step is logged and does
1127
+ * not stop the ones behind it. */
1128
+ enqueueRotation(deviceId, step) {
1129
+ const next = (this.rotations.get(deviceId) ?? Promise.resolve())
1130
+ .then(step)
1131
+ .catch((e) => { logger.error('rotate failed:', e); });
1132
+ this.rotations.set(deviceId, next);
1133
+ void next.then(() => { if (this.rotations.get(deviceId) === next)
1134
+ this.rotations.delete(deviceId); });
1135
+ return next;
1136
+ }
1008
1137
  handleRelayMessage(msg) {
1009
1138
  switch (msg.type) {
1010
1139
  case 'device:boot': {
@@ -1080,8 +1209,9 @@ export class IOSAgent {
1080
1209
  respond({ type: 'app:launch-done' });
1081
1210
  })
1082
1211
  .catch((e) => {
1083
- const message = e instanceof Error ? e.message : String(e);
1084
- respond({ type: 'app:launch-error', message });
1212
+ // The toast gets the reason alone; the full simctl output stays in this log.
1213
+ console.warn('[tapflow] app:launch failed:', e);
1214
+ respond({ type: 'app:launch-error', message: launchFailureReason(e) });
1085
1215
  });
1086
1216
  break;
1087
1217
  }
@@ -1152,9 +1282,19 @@ export class IOSAgent {
1152
1282
  const state = this.deviceStates.get(msg.sessionId);
1153
1283
  if (!state)
1154
1284
  break;
1155
- state.orientation = state.orientation === 'portrait' ? 'landscapeRight' : 'portrait';
1156
- this.simctl.rotate(state.deviceId, state.orientation)
1157
- .catch((e) => logger.error('rotate failed:', e));
1285
+ // **A target when the viewer sends one, a toggle when it does not** (#910) — an older
1286
+ // dashboard's. The memory is read when the step runs, not when it was queued, and written only
1287
+ // once the simulator took it: a step queued behind another toggles from what that one applied.
1288
+ const sessionId = msg.sessionId;
1289
+ const target = msg.payload?.orientation;
1290
+ void this.enqueueRotation(state.deviceId, async () => {
1291
+ const live = this.deviceStates.get(sessionId) ?? state;
1292
+ const next = target
1293
+ ? (target === 'landscape' ? 'landscapeRight' : 'portrait')
1294
+ : (live.orientation === 'portrait' ? 'landscapeRight' : 'portrait');
1295
+ await this.simctl.rotate(live.deviceId, next);
1296
+ live.orientation = next;
1297
+ });
1158
1298
  break;
1159
1299
  }
1160
1300
  case 'stream:request-idr': {
@@ -1709,7 +1849,8 @@ export class IOSAgent {
1709
1849
  return await this.installFrom(udid, source, tmpDir);
1710
1850
  }
1711
1851
  finally {
1712
- fs.rmSync(tmpDir, { recursive: true, force: true });
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 });
1713
1854
  }
1714
1855
  }
1715
1856
  /** Extraction + `simctl install`, given a file this machine can actually open. */
@@ -1724,11 +1865,12 @@ export class IOSAgent {
1724
1865
  // `tmpDir`, and extracting beside it would put the two in one listing.
1725
1866
  const outDir = path.join(tmpDir, 'x');
1726
1867
  fs.mkdirSync(outDir, { recursive: true });
1727
- // tar 는 기본 무음, unzip 은 -q 로 무음화해 큰 .app 에서 verbose stdout 이 기본
1728
- // maxBuffer(1MB)를 넘겨 추출이 죽는 것을 막는다.
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.
1729
1871
  const result = isTar
1730
- ? spawnSync('tar', ['-xzf', filePath, '-C', outDir], { maxBuffer: EXTRACT_MAXBUFFER })
1731
- : spawnSync('unzip', ['-q', '-o', filePath, '-d', outDir], { maxBuffer: EXTRACT_MAXBUFFER });
1872
+ ? await runExtractor('tar', ['-xzf', filePath, '-C', outDir])
1873
+ : await runExtractor('unzip', ['-q', '-o', filePath, '-d', outDir]);
1732
1874
  // 실행 자체 실패(tar/unzip 부재=ENOENT 등)는 아카이브 무효와 구분한다.
1733
1875
  if (result.error) {
1734
1876
  const code = result.error.code ?? result.error.message;
@@ -1739,7 +1881,7 @@ export class IOSAgent {
1739
1881
  // evidence away, so a build that was fine read as corrupt — `unzip` had said
1740
1882
  // `cannot find or open`, which names a missing file rather than a bad archive, and nobody
1741
1883
  // could see it. Trimmed because the whole thing reaches a browser toast.
1742
- const detail = (result.stderr?.toString() ?? '').trim().split('\n')[0]?.slice(0, 300);
1884
+ const detail = result.stderr.trim().split('\n')[0]?.slice(0, 300);
1743
1885
  throw new ValidationError((isTar
1744
1886
  ? 'tar.gz 압축 해제 실패 — 시뮬레이터용 .tar.gz(경로 탈출/심볼릭 링크 없는)인지 확인하세요.'
1745
1887
  : 'zip 압축 해제 실패 — 시뮬레이터용 .app.zip 파일인지 확인하세요.')
@@ -2087,4 +2229,34 @@ export class IOSAgent {
2087
2229
  return this.simctl.openUrl(await this.soleLiveDeviceId(), url);
2088
2230
  }
2089
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
+ }
2090
2262
  //# sourceMappingURL=IOSAgent.js.map