Skip to content

Commit 5076d25

Browse files
committed
feat: allow proper adb reverse debug sessions
1 parent ad786e3 commit 5076d25

2 files changed

Lines changed: 135 additions & 34 deletions

File tree

lib/controllers/run-controller.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export class RunController extends EventEmitter implements IRunController {
5757
private $prepareNativePlatformService: IPrepareNativePlatformService,
5858
private $projectChangesService: IProjectChangesService,
5959
protected $projectDataService: IProjectDataService,
60+
private $staticConfig: Config.IStaticConfig,
6061
) {
6162
super();
6263
}
@@ -498,6 +499,15 @@ export class RunController extends EventEmitter implements IRunController {
498499
},
499500
);
500501

502+
// For Android + Vite HMR, own the `adb reverse` ourselves —
503+
// with our SDK-resolved adb, scoped to this exact serial, and
504+
// only after the device is up — then hand the bundler the
505+
// result via env vars. This MUST run before `prepare` (which
506+
// spawns the Vite bundler that inherits `process.env`) so the
507+
// bundler trusts the tunnel instead of racing us to spawn its
508+
// own adb during config-load. See packages/vite hardening.
509+
await this.setupAndroidViteHmrReverse(device, projectData, liveSyncInfo);
510+
501511
const prepareResultData =
502512
await this.$prepareController.prepare(prepareData);
503513

@@ -626,6 +636,105 @@ export class RunController extends EventEmitter implements IRunController {
626636
);
627637
}
628638

639+
/**
640+
* Set up `adb reverse tcp:<port> tcp:<port>` for an Android device
641+
* when the project bundles with Vite in HMR/watch mode, then export
642+
* the result to the bundler subprocess via environment variables.
643+
*
644+
* The Vite dev-host helper prefers an ADB tunnel (device-side
645+
* `127.0.0.1:<port>` → host) over the emulator's flaky slirp NAT
646+
* (`10.0.2.2`). Historically the bundler tried to wire that tunnel
647+
* itself at config-load time, racing this CLI's device discovery
648+
* over the single global adb daemon and intermittently freezing the
649+
* run at "Searching for devices…". The CLI is the right owner: it
650+
* knows the exact target serial and when the device is ready, and it
651+
* already drives a single, version-matched adb. We do the reverse
652+
* here and signal the bundler with `NS_ADB_REVERSE_READY=1` so it
653+
* never spawns adb on its own.
654+
*
655+
* Best-effort: any failure is logged at trace level and swallowed.
656+
* The bundler then falls back to its own (now hardened) adb path, or
657+
* ultimately to `10.0.2.2`, so a reverse hiccup never fails the run.
658+
*/
659+
private async setupAndroidViteHmrReverse(
660+
device: Mobile.IDevice,
661+
projectData: IProjectData,
662+
liveSyncInfo: ILiveSyncInfo,
663+
): Promise<void> {
664+
try {
665+
if (!this.$mobileHelper.isAndroidPlatform(device.deviceInfo.platform)) {
666+
return;
667+
}
668+
if (projectData.bundler !== "vite") {
669+
return;
670+
}
671+
// HMR over the tunnel only matters for a live watch session.
672+
if (liveSyncInfo.skipWatcher || !liveSyncInfo.useHotModuleReload) {
673+
return;
674+
}
675+
// Respect the user's explicit opt-out — they want the
676+
// `10.0.2.2` / LAN path, so don't create a tunnel or claim one
677+
// exists.
678+
if (this.isTruthyEnvFlag(process.env.NS_HMR_NO_ADB_REVERSE)) {
679+
return;
680+
}
681+
// `NS_HMR_PREFER_LAN_HOST` means the dev wants LAN routing
682+
// (physical device over Wi-Fi); the dev-host resolver suppresses
683+
// the adb-reverse path for it, so don't bother wiring one.
684+
if (this.isTruthyEnvFlag(process.env.NS_HMR_PREFER_LAN_HOST)) {
685+
return;
686+
}
687+
688+
const serial = device.deviceInfo.identifier;
689+
const port = this.getViteHmrPort();
690+
// Safe after the `isAndroidPlatform` guard above — only Android
691+
// devices carry the `adb` bridge.
692+
const adb = (device as Mobile.IAndroidDevice).adb;
693+
694+
// Don't `reverse` against a device whose adbd isn't accepting
695+
// yet (emulators report a `device` transport before adbd is
696+
// fully up). `wait-for-device` returns immediately once ready.
697+
await adb.executeCommand(["wait-for-device"], {
698+
deviceIdentifier: serial,
699+
});
700+
await adb.executeCommand(["reverse", `tcp:${port}`, `tcp:${port}`], {
701+
deviceIdentifier: serial,
702+
});
703+
704+
// Hand the exact adb the CLI used to the bundler so, in any
705+
// fallback path, it drives the same version-matched client and
706+
// can't trigger a server-version-mismatch daemon kill.
707+
const adbPath = await this.$staticConfig.getAdbFilePath();
708+
process.env.NS_ADB_PATH = adbPath;
709+
process.env.NS_DEVICE_SERIAL = serial;
710+
process.env.NS_ADB_REVERSE_READY = "1";
711+
712+
this.$logger.info(
713+
`Set up adb reverse tcp:${port} tcp:${port} for ${serial} (Vite HMR routes device-side 127.0.0.1:${port} through ADB).`,
714+
);
715+
} catch (err) {
716+
this.$logger.trace(
717+
`Setting up adb reverse for Vite HMR failed; leaving it to the bundler fallback. Error: ${err}`,
718+
);
719+
}
720+
}
721+
722+
private getViteHmrPort(): number {
723+
// The Vite dev server defaults to 5173; the bundler reads the same
724+
// default. If a project runs Vite on a different port, the dev sets
725+
// `NS_HMR_PORT` so the CLI reverses the matching port.
726+
const fromEnv = Number(process.env.NS_HMR_PORT);
727+
return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : 5173;
728+
}
729+
730+
private isTruthyEnvFlag(value: string | undefined): boolean {
731+
if (typeof value !== "string") {
732+
return false;
733+
}
734+
const v = value.trim().toLowerCase();
735+
return !!v && v !== "0" && v !== "false" && v !== "off" && v !== "no";
736+
}
737+
629738
private async syncChangedDataOnDevices(
630739
data: IFilesChangeEventData,
631740
projectData: IProjectData,

test/controllers/run-controller.ts

Lines changed: 26 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,12 @@ function getFullSyncResult(): ILiveSyncResultInfo {
6969
}
7070

7171
function mockDevicesService(injector: IInjector, devices: Mobile.IDevice[]) {
72-
const devicesService: Mobile.IDevicesService = injector.resolve(
73-
"devicesService"
74-
);
72+
const devicesService: Mobile.IDevicesService =
73+
injector.resolve("devicesService");
7574
devicesService.execute = async (
7675
action: (device: Mobile.IDevice) => Promise<any>,
7776
canExecute?: (dev: Mobile.IDevice) => boolean,
78-
options?: { allowNoDevices?: boolean }
77+
options?: { allowNoDevices?: boolean },
7978
) => {
8079
for (const d of devices) {
8180
if (canExecute(<any>d)) {
@@ -132,12 +131,15 @@ function createTestInjector() {
132131
injector.register("debugController", {});
133132
injector.register("liveSyncProcessDataService", LiveSyncProcessDataService);
134133
injector.register("tempService", TempServiceStub);
134+
injector.register("staticConfig", {
135+
getAdbFilePath: async () => "adb",
136+
});
135137

136138
const devicesService = injector.resolve("devicesService");
137139
devicesService.getDevicesForPlatform = () =>
138140
<any>[{ identifier: "myTestDeviceId1" }];
139141
devicesService.getPlatformsFromDeviceDescriptors = (
140-
devices: ILiveSyncDeviceDescriptor[]
142+
devices: ILiveSyncDeviceDescriptor[],
141143
) => devices.map((d) => map[d.identifier].device.deviceInfo.platform);
142144
devicesService.on = () => ({});
143145

@@ -206,20 +208,17 @@ describe("RunController", () => {
206208
describe("watch", () => {
207209
const testCases = [
208210
{
209-
name:
210-
"should prepare only ios platform when only ios devices are connected",
211+
name: "should prepare only ios platform when only ios devices are connected",
211212
connectedDevices: [iOSDeviceDescriptor],
212213
expectedPreparedPlatforms: ["ios"],
213214
},
214215
{
215-
name:
216-
"should prepare only android platform when only android devices are connected",
216+
name: "should prepare only android platform when only android devices are connected",
217217
connectedDevices: [androidDeviceDescriptor],
218218
expectedPreparedPlatforms: ["android"],
219219
},
220220
{
221-
name:
222-
"should prepare both platforms when ios and android devices are connected",
221+
name: "should prepare both platforms when ios and android devices are connected",
223222
connectedDevices: [iOSDeviceDescriptor, androidDeviceDescriptor],
224223
expectedPreparedPlatforms: ["ios", "android"],
225224
},
@@ -229,15 +228,14 @@ describe("RunController", () => {
229228
it(testCase.name, async () => {
230229
mockDevicesService(
231230
injector,
232-
testCase.connectedDevices.map((d) => map[d.identifier].device)
231+
testCase.connectedDevices.map((d) => map[d.identifier].device),
233232
);
234233

235234
const preparedPlatforms: string[] = [];
236-
const prepareController: PrepareController = injector.resolve(
237-
"prepareController"
238-
);
235+
const prepareController: PrepareController =
236+
injector.resolve("prepareController");
239237
prepareController.prepare = async (
240-
currentPrepareData: PrepareData
238+
currentPrepareData: PrepareData,
241239
) => {
242240
preparedPlatforms.push(currentPrepareData.platform);
243241
return {
@@ -253,7 +251,7 @@ describe("RunController", () => {
253251

254252
assert.deepStrictEqual(
255253
preparedPlatforms,
256-
testCase.expectedPreparedPlatforms
254+
testCase.expectedPreparedPlatforms,
257255
);
258256
});
259257
});
@@ -263,41 +261,35 @@ describe("RunController", () => {
263261
describe("stopRunOnDevices", () => {
264262
const testCases = [
265263
{
266-
name:
267-
"stops LiveSync operation for all devices and emits liveSyncStopped for all of them when stopLiveSync is called without deviceIdentifiers",
264+
name: "stops LiveSync operation for all devices and emits liveSyncStopped for all of them when stopLiveSync is called without deviceIdentifiers",
268265
currentDeviceIdentifiers: ["device1", "device2", "device3"],
269266
expectedDeviceIdentifiers: ["device1", "device2", "device3"],
270267
},
271268
{
272-
name:
273-
"stops LiveSync operation for all devices and emits liveSyncStopped for all of them when stopLiveSync is called without deviceIdentifiers (when a single device is attached)",
269+
name: "stops LiveSync operation for all devices and emits liveSyncStopped for all of them when stopLiveSync is called without deviceIdentifiers (when a single device is attached)",
274270
currentDeviceIdentifiers: ["device1"],
275271
expectedDeviceIdentifiers: ["device1"],
276272
},
277273
{
278-
name:
279-
"stops LiveSync operation for specified devices and emits liveSyncStopped for each of them (when a single device is attached)",
274+
name: "stops LiveSync operation for specified devices and emits liveSyncStopped for each of them (when a single device is attached)",
280275
currentDeviceIdentifiers: ["device1"],
281276
expectedDeviceIdentifiers: ["device1"],
282277
deviceIdentifiersToBeStopped: ["device1"],
283278
},
284279
{
285-
name:
286-
"stops LiveSync operation for specified devices and emits liveSyncStopped for each of them",
280+
name: "stops LiveSync operation for specified devices and emits liveSyncStopped for each of them",
287281
currentDeviceIdentifiers: ["device1", "device2", "device3"],
288282
expectedDeviceIdentifiers: ["device1", "device3"],
289283
deviceIdentifiersToBeStopped: ["device1", "device3"],
290284
},
291285
{
292-
name:
293-
"does not raise liveSyncStopped event for device, which is not currently being liveSynced",
286+
name: "does not raise liveSyncStopped event for device, which is not currently being liveSynced",
294287
currentDeviceIdentifiers: ["device1", "device2", "device3"],
295288
expectedDeviceIdentifiers: ["device1"],
296289
deviceIdentifiersToBeStopped: ["device1", "device4"],
297290
},
298291
{
299-
name:
300-
"stops LiveSync operation for all devices when stop method is called with empty array",
292+
name: "stops LiveSync operation for all devices when stop method is called with empty array",
301293
currentDeviceIdentifiers: ["device1", "device2", "device3"],
302294
expectedDeviceIdentifiers: ["device1", "device2", "device3"],
303295
deviceIdentifiersToBeStopped: [],
@@ -307,22 +299,22 @@ describe("RunController", () => {
307299
for (const testCase of testCases) {
308300
it(testCase.name, async () => {
309301
const liveSyncProcessDataService = injector.resolve(
310-
"liveSyncProcessDataService"
302+
"liveSyncProcessDataService",
311303
);
312304
(<any>liveSyncProcessDataService).persistData(
313305
projectDir,
314306
testCase.currentDeviceIdentifiers.map(
315-
(identifier) => <any>{ identifier }
307+
(identifier) => <any>{ identifier },
316308
),
317-
["ios"]
309+
["ios"],
318310
);
319311

320312
const emittedDeviceIdentifiersForLiveSyncStoppedEvent: string[] = [];
321313

322314
runController.on(RunOnDeviceEvents.runOnDeviceStopped, (data: any) => {
323315
assert.equal(data.projectDir, projectDir);
324316
emittedDeviceIdentifiersForLiveSyncStoppedEvent.push(
325-
data.deviceIdentifier
317+
data.deviceIdentifier,
326318
);
327319
});
328320

@@ -333,7 +325,7 @@ describe("RunController", () => {
333325

334326
assert.deepStrictEqual(
335327
emittedDeviceIdentifiersForLiveSyncStoppedEvent,
336-
testCase.expectedDeviceIdentifiers
328+
testCase.expectedDeviceIdentifiers,
337329
);
338330
});
339331
}

0 commit comments

Comments
 (0)