[Bug]: A physical Android device hides a never-booted AVD with the same display name #35984
Send to my agentSummary#
A never-booted Android virtual device disappears from discovery when a connected physical Android device has the same display name. Both devices should remain available because they have different identities and only the virtual device needs booting. This was reproduced against the production discovery adapter with mocked hub HTTP and a fake emulator executable; a different-name control lists both devices.
Before submitting#
- Related issues: searches of open and closed issues and PRs found no report of physical-device display names suppressing AVD discovery; related identity history appears under Additional context.
- Removed credentials, tokens, private paths, and sensitive data; included enough detail to reproduce or investigate the problem.
Area#
apps/server
Observed behavior#
Given a hub response containing a physical Android device with id phone-serial and name TestPhone, and a successful emulator -list-avds response containing the never-booted AVD TestPhone, makeDeviceHubOperations().list returns only phone-serial. Changing only the physical device's display name to DifferentPhone returns both phone-serial and the stopped AVD TestPhone. No hub warning or AVD-listing error is required for the omission.
Reproduction#
- Run the script below against a clean checkout of the stated commit.
- The script loads the production discovery adapter and contract schemas, supplies a valid physical-device hub response, and invokes a fake emulator that implements only
-list-avds. - Compare the different-name control with the same-name input.
The final script reproduced the failure on its one recorded run; an earlier exploratory adapter probe found the same omission. No real device, emulator process, AVD data, or live T3 state was accessed.
Save the script as repro-name-collision.mjs and run node repro-name-collision.mjs /path/to/checkout. It requires Node with TypeScript stripping and synchronous module hooks (the observed run used Node 26), npm, Git, and registry access. It installs only the repository-pinned Effect package into a fresh temporary directory, places HOME, TMPDIR, T3 state, SDK, and Android user-state paths there, and removes that directory on exit. Module hooks resolve Effect and the production device schemas without modifying the checkout. Exit 1 means the AVD was omitted, exit 0 means both devices were retained, and exit 2 means setup or the control failed.
Reproduction script: repro-name-collision.mjs
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { spawnSync } from 'node:child_process';
import { registerHooks } from 'node:module';
import { pathToFileURL } from 'node:url';
const checkout = resolve(process.argv[2] || '.');
const scratch = mkdtempSync(join(tmpdir(), 't3-avd-name-'));
let status = 2;
try {
for (const name of ['home', 'tmp', 'state', 'sdk', 'android-user', 'avds', 'emulator-home']) {
mkdirSync(join(scratch, name));
}
Object.assign(process.env, {
HOME: join(scratch, 'home'), TMPDIR: join(scratch, 'tmp'),
T3CODE_HOME: join(scratch, 'state'), ANDROID_HOME: join(scratch, 'sdk'),
ANDROID_SDK_ROOT: join(scratch, 'sdk'), ANDROID_USER_HOME: join(scratch, 'android-user'),
ANDROID_AVD_HOME: join(scratch, 'avds'), ANDROID_EMULATOR_HOME: join(scratch, 'emulator-home'),
PROBE_AVD_NAME: 'TestPhone',
});
const command = (bin, args) => {
const result = spawnSync(bin, args, { encoding: 'utf8', env: process.env });
if (result.error || result.status !== 0) throw new Error(`${bin} setup command failed`);
return result.stdout.trim();
};
console.log(`node ${process.version}`);
console.log(`npm ${command('npm', ['--version'])}`);
console.log(command('git', ['--version']));
console.log(`checkout ${command('git', ['-C', checkout, 'rev-parse', 'HEAD'])}`);
command('npm', ['install', '--prefix', scratch, '--ignore-scripts', '--no-audit', '--no-fund', '--package-lock=false', 'effect@4.1.3']);
console.log('effect 4.1.3 (repository pin)');
const fakeEmulator = join(scratch, 'fake-emulator');
writeFileSync(fakeEmulator, '#!/bin/sh\ncase "$*" in\n -list-avds) printf "%s\\n" "$PROBE_AVD_NAME" ;;\n *) exit 2 ;;\nesac\n', { mode: 0o700 });
const moduleParent = pathToFileURL(join(scratch, 'package.json')).href;
registerHooks({ resolve(specifier, context, nextResolve) {
if (specifier === '@t3tools/contracts') return { url: pathToFileURL(join(checkout, 'packages/contracts/src/device.ts')).href, shortCircuit: true };
if (specifier === 'effect' || specifier.startsWith('effect/')) return nextResolve(specifier, { ...context, parentURL: moduleParent });
return nextResolve(specifier, context);
} });
const Effect = await import('effect/Effect');
const { HttpClient, HttpClientResponse } = await import('effect/http');
const { makeDeviceHubOperations } = await import(pathToFileURL(join(checkout, 'apps/server/src/device/DeviceHubOperations.ts')));
let physicalName = 'DifferentPhone';
const operations = makeDeviceHubOperations({
hostId: 'local', origin: 'http://device.test',
http: HttpClient.make(request => Effect.sync(() => {
if (request.method !== 'GET' || !request.url.endsWith('/api/devices')) throw new Error('Unexpected HTTP request');
return HttpClientResponse.fromWeb(request, Response.json({ simulators: [], emulators: [{
id: 'phone-serial', name: physicalName, platform: 'android', version: 'Android 16', booted: true, physical: true,
}], errors: [] }));
})),
run: (bin, args) => Effect.sync(() => {
if (bin !== 'emulator' || args.length !== 1 || args[0] !== '-list-avds') throw new Error('Unexpected subprocess request');
const result = spawnSync(fakeEmulator, args, { encoding: 'utf8', env: process.env });
if (result.error) throw result.error;
return { code: result.status ?? 127, stdout: result.stdout, stderr: result.stderr };
}),
platformAvailable: () => Effect.succeed(true),
});
const containsBoth = devices => devices.some(d => d.id === 'phone-serial' && d.physical)
&& devices.some(d => d.id === 'TestPhone' && !d.physical && !d.booted);
const control = await Effect.runPromise(operations.list);
if (!containsBoth(control.devices)) throw new Error('Different-name control did not list both devices');
console.log('control: different names list physical phone and stopped AVD');
physicalName = 'TestPhone';
const collision = await Effect.runPromise(operations.list);
console.log(`same-name IDs: ${collision.devices.map(d => d.id).join(', ')}`);
status = containsBoth(collision.devices) ? 0 : 1;
} catch (error) {
console.log(`setup/control error: ${error.message}`);
} finally {
rmSync(scratch, { recursive: true, force: true });
}
console.log(status === 0 ? 'RESULT: expected behavior' : status === 1 ? 'RESULT: defect reproduced' : 'RESULT: inconclusive');
process.exitCode = status;
Expected behavior#
The stopped AVD and physical device should both be listed even when their display names match. The adapter supplements the hub response with installed AVD names in apps/server/src/device/DeviceHubOperations.ts#L164-L185, and packages/contracts/src/device.ts#L172-L184 keeps identity, display name, and physical-device status as separate fields. Android's emulator reference defines -list-avds as AVD names, whereas adb device status identifies connected transports by serial.
The pinned hub can produce an unprefixed physical-device model name when its manufacturer property is absent or empty, as shown in packages/@expo/hub-android-utils/src/device-mapping.ts#L68-L91. Such a display name can also be a valid AVD name. The pinned hub omits AVDs with no successful boot marker, so the T3 supplement is necessary for this case, as shown in packages/expo-device-hub/src/server/devices.ts.
Impact and scope#
Impact: P2 - A specific installed AVD becomes unavailable through discovery when a physical device shares its display name. Other devices remain available and no data is changed.
The adapter failure is reproduced with valid hub and subprocess boundary inputs and applies to local and SSH hosts that use this adapter. Live hardware frequency, GUI behavior, and a user-facing workaround are NOT_ENOUGH_INFO: no physical device or emulator was operated; a live setup with the stated naming condition would establish those observations. The different-name control establishes that the collision causes the omission, without claiming an actual device or AVD was renamed.
Version#
T3 Code commit 59e64eef881632a23f89c4138bcf37f23d92bf93; runtime tool pins expo-device-hub@0.22.1 and agent-device@0.21.36. This reproducer loads T3 source and mocks the hub HTTP boundary; it does not execute those device CLIs.
Runtime and platform#
Node v26.12.1, npm 11.20.0 as resolved under disposable HOME, Git 2.56.0, Effect 4.1.3, macOS arm64. No Android SDK or guest image is required; the emulator executable is a shell fixture.
Interface#
API: makeDeviceHubOperations().list, shared by local and SSH device discovery and exposed through device.list / device_list.
Earliest failure stage#
Core processing
Relevant output or evidence#
Output from the final reproduction script:
node v26.12.1
npm 11.20.0
git version 2.56.0
checkout 59e64eef881632a23f89c4138bcf37f23d92bf93
effect 4.1.3 (repository pin)
control: different names list physical phone and stopped AVD
same-name IDs: phone-serial
RESULT: defect reproduced
The exclusion predicate checks only Android platform and display-name equality in apps/server/src/device/DeviceHubOperations.ts#L174-L185. The retained physical device satisfies that predicate despite being a different device from the AVD. The nearby different-name control uses the same HTTP schema, fake executable, and production module imports.
Additional context#
- https://github.com/pingdotgg/t3code/issues/30273 — covers AVD names changing to serials across boot and failed refreshes; this report concerns an AVD omitted before any boot because another physical device shares its display name.
- https://github.com/pingdotgg/t3code/pull/30289 — changes device lookup after boot; the discovery adapter's physical-name collision is a separate acceptance condition.
tanstack-source added labelsbug
Sign in to join this conversation.