From 69eb153e36f90f0e89b0f72c12ba60c1a85a1f79 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Thu, 3 Sep 2026 17:55:44 -0300 Subject: [PATCH] fix: only fail an operation when the device it targets is lost _getPromise subscribed every in-flight operation to the global deviceLost event and rejected as soon as any device was lost, without checking whether the lost device was the one the operation was addressed to. With two devices attached (or one flapping over Wi-Fi, or re-pairing, which the stdio handler reports as lost + found), an upload to one device was rejected with "Device lost during operation upload" and the caller aborted a transfer that was still succeeding on the target device. The lost handler now derives the target device identifiers from the method arguments and ignores events for other devices. Operations whose arguments carry no device identifier keep the previous behaviour. The rejection error now also carries the lost device's identifier as deviceId, matching the IDeviceError shape the binary produces, so callers can attribute it. --- ios-device-lib.js | 30 ++++++++- test/ios-device-lib.js | 138 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 test/ios-device-lib.js diff --git a/ios-device-lib.js b/ios-device-lib.js index 322a771..2ebc5b2 100644 --- a/ios-device-lib.js +++ b/ios-device-lib.js @@ -128,11 +128,18 @@ class IOSDeviceLib extends EventEmitter { message.error ? reject(message.error) : resolve(message); }; + const targetDeviceIds = this._getTargetDeviceIds(args); deviceLostHandler = (device) => { + if (targetDeviceIds.size && !targetDeviceIds.has(device.deviceId)) { + return; + } + let message = `Device ${device.deviceId} lost during operation ${methodName} for message ${id}`; if (!options.doNotFailOnDeviceLost) { - message = { error: new Error(message) }; + const error = new Error(message); + error.deviceId = device.deviceId; + message = { error }; } handleMessage(message); @@ -170,6 +177,27 @@ class IOSDeviceLib extends EventEmitter { _getMessage(id, name, args) { return JSON.stringify({ methods: [{ id: id, name: name, args: args }] }) + '\n'; } + + // Device identifiers appear in method args either as bare strings (apps, log, + // and the nested array of install/uninstall) or as a deviceId property on an + // operation object. Other strings in args (an IPA path, an app id) are + // collected too; they can never equal a device identifier, so they cannot + // cause a lost-device event to be matched to the wrong operation. + _getTargetDeviceIds(args) { + const deviceIds = new Set(); + const collect = (value) => { + if (typeof value === "string") { + deviceIds.add(value); + } else if (Array.isArray(value)) { + value.forEach(collect); + } else if (value && typeof value === "object" && typeof value.deviceId === "string") { + deviceIds.add(value.deviceId); + } + }; + + collect(args); + return deviceIds; + } } exports.IOSDeviceLib = IOSDeviceLib; diff --git a/test/ios-device-lib.js b/test/ios-device-lib.js new file mode 100644 index 0000000..12e819c --- /dev/null +++ b/test/ios-device-lib.js @@ -0,0 +1,138 @@ +const { assert } = require("chai"); +const EventEmitter = require("events"); +const Constants = require("../constants"); +const { IOSDeviceLib } = require("../ios-device-lib"); + +describe("ios-device-lib", () => { + describe("device lost during an operation", () => { + const deviceA = "00008132-0016111C0E05001C"; + const deviceB = "00008150-0019290136E2401C"; + + // Builds an instance whose stdio handler is a bare emitter, so no binary + // is spawned and device events can be raised directly. + const createLib = (options) => { + const lib = Object.create(IOSDeviceLib.prototype); + EventEmitter.call(lib); + lib._options = options || {}; + lib._iosDeviceLibStdioHandler = new EventEmitter(); + lib._iosDeviceLibStdioHandler.written = []; + lib._iosDeviceLibStdioHandler.writeData = (data) => { + lib._iosDeviceLibStdioHandler.written.push(JSON.parse(data)); + }; + return lib; + }; + + const lastMessageId = (lib) => { + const written = lib._iosDeviceLibStdioHandler.written; + return written[written.length - 1].methods[0].id; + }; + + const emitDeviceLost = (lib, deviceId) => { + lib._iosDeviceLibStdioHandler.emit(Constants.DeviceLostEventName, { + event: Constants.DeviceEventEnum.kDeviceLost, + deviceId + }); + }; + + const emitResponse = (lib, id, response) => { + lib._iosDeviceLibStdioHandler.emit(Constants.DataEventName, Object.assign({ id }, response)); + }; + + // Resolves with { state, value } after the current microtask queue drains, + // so a promise that must stay pending can be asserted on. + const settle = (promise) => { + let result = { state: "pending" }; + promise.then( + (value) => { result = { state: "resolved", value }; }, + (error) => { result = { state: "rejected", error }; } + ); + return new Promise((resolve) => setImmediate(() => resolve(result))); + }; + + it("rejects when the device the operation targets is lost", async () => { + const lib = createLib(); + const [promise] = lib.upload([{ deviceId: deviceA, appId: "org.example.app", files: [] }]); + + emitDeviceLost(lib, deviceA); + + const result = await settle(promise); + assert.equal(result.state, "rejected"); + assert.include(result.error.message, `Device ${deviceA} lost during operation upload`); + assert.equal(result.error.deviceId, deviceA); + }); + + it("ignores a lost event for a device the operation does not target", async () => { + const lib = createLib(); + const [promise] = lib.upload([{ deviceId: deviceA, appId: "org.example.app", files: [] }]); + + emitDeviceLost(lib, deviceB); + assert.equal((await settle(promise)).state, "pending"); + + emitResponse(lib, lastMessageId(lib), { deviceId: deviceA, response: "ok" }); + const result = await settle(promise); + assert.equal(result.state, "resolved"); + assert.deepEqual(result.value, { deviceId: deviceA, response: "ok" }); + }); + + it("keeps listening for the target device after another device is lost", async () => { + const lib = createLib(); + const [promise] = lib.upload([{ deviceId: deviceA, appId: "org.example.app", files: [] }]); + + emitDeviceLost(lib, deviceB); + emitDeviceLost(lib, deviceA); + + const result = await settle(promise); + assert.equal(result.state, "rejected"); + assert.equal(result.error.deviceId, deviceA); + }); + + it("matches the device identifier nested in install arguments", async () => { + const lib = createLib(); + const [promise] = lib.install("/tmp/app.ipa", [deviceA]); + + emitDeviceLost(lib, deviceB); + assert.equal((await settle(promise)).state, "pending"); + + emitDeviceLost(lib, deviceA); + const result = await settle(promise); + assert.equal(result.state, "rejected"); + assert.equal(result.error.deviceId, deviceA); + }); + + it("matches the bare device identifier of apps arguments", async () => { + const lib = createLib(); + const [promise] = lib.apps([deviceA]); + + emitDeviceLost(lib, deviceB); + assert.equal((await settle(promise)).state, "pending"); + + emitDeviceLost(lib, deviceA); + assert.equal((await settle(promise)).state, "rejected"); + }); + + it("rejects on any lost device when the operation names no device", async () => { + const lib = createLib(); + const [promise] = lib.awaitNotificationResponse([{ socket: 3, timeout: 1000 }]); + + emitDeviceLost(lib, deviceB); + + const result = await settle(promise); + assert.equal(result.state, "rejected"); + assert.equal(result.error.deviceId, deviceB); + }); + + it("does not fail device log streaming for another device", async () => { + const lib = createLib(); + lib.startDeviceLog([deviceA]); + const handler = lib._iosDeviceLibStdioHandler; + + emitDeviceLost(lib, deviceB); + assert.equal(handler.listenerCount(Constants.DeviceLostEventName), 1); + assert.equal(handler.listenerCount(Constants.DataEventName), 1); + + emitDeviceLost(lib, deviceA); + assert.equal(handler.listenerCount(Constants.DeviceLostEventName), 0); + assert.equal(handler.listenerCount(Constants.DataEventName), 0); + }); + }); +});