Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/docs/docs/reference/device-providers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ An external device appears in `list-devices` with the other devices. Its id star
| `external` | Always `true`. It marks the device as the device of a provider. |
| `provider` | The id and the name of the provider, and the project that the provider opened. |
| `capabilities` | The mechanisms that the provider granted for this device. |
| `foldable` | `true` for a foldable iOS simulator, as on a plain entry. Absent otherwise. |

The agent drives an external device with the usual tools. Two rules are different:

Expand Down
7 changes: 2 additions & 5 deletions packages/tool-server/src/blueprints/ax-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from "@argent/registry";
import { assertExternalCapability } from "../utils/external-devices";
import { pickIosHost, type IosEndpoint } from "../utils/ios-host";
import { axServiceSocketPath } from "../utils/socket-tag";

// Moved to ../utils/ax-prefs; re-exported so existing import paths keep working.
export {
Expand Down Expand Up @@ -159,10 +160,6 @@ const LIVE_PANEL_TIMEOUT_MS = 2_000;
*/
const FILL_TIMEOUT_MS = 6_000;

function getSocketPath(udid: string): string {
return `/tmp/ax-${udid.slice(0, 8)}.sock`;
}

interface PendingRpc {
resolve: (value: unknown) => void;
reject: (err: Error) => void;
Expand Down Expand Up @@ -345,7 +342,7 @@ export const axServiceBlueprint: ServiceBlueprint<AXServiceApi, DeviceInfo> = {
const endpoint: IosEndpoint =
transport === "tcp"
? { transport: "tcp" }
: { transport: "unix", socketPath: getSocketPath(udid) };
: { transport: "unix", socketPath: axServiceSocketPath(udid) };
const events = new TypedEventEmitter<ServiceEvents>();

const pendingRpc = new Map<number, PendingRpc>();
Expand Down
9 changes: 2 additions & 7 deletions packages/tool-server/src/blueprints/native-devtools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type ServiceEvents,
} from "@argent/registry";
import { assertExternalCapability, externalClaimForAnyId } from "../utils/external-devices";
import { nativeDevtoolsSocketPath } from "../utils/socket-tag";
import {
pickIosHost,
buildDyldInsertLibraries,
Expand Down Expand Up @@ -501,12 +502,6 @@ interface AppConnection {
networkLog: NetworkEvent[];
}

function getNativeDevtoolsSocketPath(udid: string): string {
// Deterministic and short: 28 chars, well under the 104-char macOS Unix
// socket limit.
return `/tmp/argent-nd-${udid.slice(0, 8)}.sock`;
}

/**
* Bind the per-UDID unix socket with the same guarded, self-healing treatment
* the TCP branch gets. Exported for testing.
Expand Down Expand Up @@ -639,7 +634,7 @@ export const nativeDevtoolsBlueprint: ServiceBlueprint<NativeDevtoolsApi, Device
: (opts.transport ?? "unix");

const udid = device.id;
const socketPath = getNativeDevtoolsSocketPath(udid);
const socketPath = nativeDevtoolsSocketPath(udid);
// For TCP, `port` starts undefined (ephemeral) and is populated by the
// listen block below, before ensureEnvReady and dispose read it.
const endpoint: IosEndpoint =
Expand Down
45 changes: 39 additions & 6 deletions packages/tool-server/src/tools/devices/list-devices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
ADB_DEVICES_TIMEOUT_MS,
} from "../../utils/adb";
import { listRunningVvdConsolePorts } from "../../utils/vega-process";
import { listIosSimulators, type IosSimulator } from "../../utils/ios-devices";
import { isFoldableSimulator, listIosSimulators, type IosSimulator } from "../../utils/ios-devices";
import { listIosPhysicalDevices } from "../../utils/ios-device/devicectl";
import { simctlListDevices } from "../../utils/sim-remote";
import { withRemotePrefix } from "../../utils/device-info";
Expand Down Expand Up @@ -72,6 +72,11 @@ type AndroidDevice = {
type ExternalListedDevice = {
capabilities: string[];
external: true;
/**
* Present and true for a foldable simulator (the iPhone Duo), as on a plain
* iOS row.
*/
foldable?: true;
Comment on lines +75 to +79

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat, Opus 5.5]: An external row can now carry foldable: true, but the "What the agent sees" section of packages/docs/docs/reference/device-providers.mdx still lists only external, provider and capabilities as the extra fields of an external entry. reference/tools.mdx and fundamentals/supported-platforms.mdx describe the flag only for plain simulators. The repo CLAUDE.md asks for the docs update in the same PR, or a line in the description saying none is needed. This PR has neither.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added foldable to the "What the agent sees" table.

id: string;
kind: "device" | "emulator" | "simulator";
name: string;
Expand Down Expand Up @@ -233,10 +238,11 @@ export async function withDeadline<T>(p: Promise<T>, fallback: T, label: string)
}
}

function toListedExternal(device: ExternalDevice): ExternalListedDevice {
function toListedExternal(device: ExternalDevice, foldable: boolean): ExternalListedDevice {
return {
capabilities: Array.from(device.capabilities).sort(),
external: true,
...(foldable ? { foldable: true as const } : {}),
id: device.id,
kind: device.kind,
name: device.name,
Expand All @@ -252,6 +258,30 @@ function toListedExternal(device: ExternalDevice): ExternalListedDevice {
};
}

type ExternalEntry = { device: ExternalDevice; foldable: boolean };

/**
* Pair each external device with whether it is a foldable simulator (the iPhone
* Duo), read from the provider's device set. An agent needs the flag to know
* `fold` applies, whoever drives the device. This runs inside the external
* discovery branch, so it overlaps the other branches instead of adding to
* them; the branch's worst case (a provider probe, then one `simctl` listing
* and one device-type read) stays under `BRANCH_DEADLINE_MS`.
Comment on lines +268 to +269

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat, Opus 5.5]: The worst case is longer than this says. After the probe (800 ms), the chain can run simctl --set <set> list devices (10 s), then simctl list devicetypes (10 s). It then runs the re-read that deviceTypeBundlePath does on a miss (another 10 s; a timed-out listing comes back as an empty map, so it always misses), then plutil (5 s). That is 35.8 s against the 25 s deadline. list-devices-deadline.test.ts pins the Vega and Android sums, but not this branch. When the deadline fires, withDeadline replaces the whole external result with []. That call then lists no lent device from any provider, including lent Android emulators, which never needed the check. On main this branch was the probe alone.

I reproduced it through the HTTP entry point (dist/index.js start, POST /tools/list-devices). An xcrun shim delays every simctl call by 8.5 s, and the lent Duo's type is missing from list devicetypes. The descriptor lends two iOS simulators and emulator-5554.

  • 91542bd: call 1 took 9 s and listed all 3 ext: rows.
  • 4b62d5c: call 1 took 25 s and listed no ext: rows. The log said [list-devices] external discovery exceeded 25000ms; returning partial results, and call 2 recovered.

An in-process run gave the same result: a 6 s --set listing plus a hanging list devicetypes returned external=[] after 25020 ms, against the row in 30 ms on main. A full wedge, where every simctl call hangs, stops at about 10 s and does not trip the deadline.

* `isFoldableSimulator` memoizes every verdict the device set answers, so a
* simulator the set lists costs one `simctl` call per tool-server run.
*/
async function withFoldableFlags(external: readonly ExternalDevice[]): Promise<ExternalEntry[]> {
return Promise.all(
external.map(async (device) => ({
device,
foldable:
device.platform === "ios" &&
device.kind === "simulator" &&
(await isFoldableSimulator(device.id).catch(() => false)),
}))
);
}

/**
* Native ids a provider is currently claiming.
*
Expand Down Expand Up @@ -332,16 +362,19 @@ Booted/ready devices are listed first. Platforms whose CLI is unavailable are si
),
/**
* One directory stat when no provider is registered (the common case)
* and a short HTTP probe per provider otherwise.
* and a short HTTP probe per provider otherwise, then the foldable
* check of each lent simulator.
*/
withDeadline(
listExternalDevices().catch(() => []),
listExternalDevices()
.catch(() => [])
.then(withFoldableFlags),
[],
"external"
),
]);

const externalShadows = externalShadowIds(external);
const externalShadows = externalShadowIds(external.map(({ device }) => device));

const iosTagged: IosDevice[] = ios
.filter((simulator) => !externalShadows.has(simulator.udid))
Expand Down Expand Up @@ -389,7 +422,7 @@ Booted/ready devices are listed first. Platforms whose CLI is unavailable are si
...androidDeduped,
...chromium,
...vega,
...external.map(toListedExternal),
...external.map(({ device, foldable }) => toListedExternal(device, foldable)),
];
devices.sort((a, b) => readinessRank(a) - readinessRank(b));

Expand Down
29 changes: 29 additions & 0 deletions packages/tool-server/src/utils/socket-tag.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { createHash } from "node:crypto";

/**
* The per-device part of a `/tmp/<prefix>-<tag>.sock` path.
*
* A plain UDID gives its first 8 characters. Any other id, such as an external
* provider's `ext:<provider>:<udid>`, gives 12 hex characters of its SHA-256:
* the first 8 characters of such an id are `ext:` plus the first 4 of the
* provider id, the same for every device of that provider and of any provider
* whose id starts the same way. The hash keeps the path short (macOS
* caps `sun_path` at 104 bytes) and can never equal an 8-character UDID tag.
*/
export function socketTag(deviceId: string): string {
if (/^[A-Za-z0-9-]+$/.test(deviceId)) return deviceId.slice(0, 8);
return createHash("sha256").update(deviceId).digest("hex").slice(0, 12);
}

/** The Unix socket the ax-service daemon of a device listens on. */
export function axServiceSocketPath(deviceId: string): string {
return `/tmp/ax-${socketTag(deviceId)}.sock`;
}

/**
* The Unix socket the native-devtools library of a device connects to. At most
* 32 characters, well under the 104-byte macOS limit.
*/
export function nativeDevtoolsSocketPath(deviceId: string): string {
return `/tmp/argent-nd-${socketTag(deviceId)}.sock`;
}
2 changes: 1 addition & 1 deletion packages/tool-server/test/blueprints/ax-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { DeviceInfo } from "@argent/registry";
import { axServiceBlueprint } from "../../src/blueprints/ax-service";

// Regression: same crash class as simulator-server. A missing udid would
// throw `getSocketPath(undefined).slice` synchronously and `udid.slice` in
// reach `axServiceSocketPath(undefined)` and throw `udid.slice` in
// the stderr listener fatally. The id-shape check sits *after* the apple-
// only check so an Android caller still gets the clearer iOS-only error.
describe("ax-service blueprint — input validation", () => {
Expand Down
4 changes: 2 additions & 2 deletions packages/tool-server/test/helpers/process-scoped-udid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@
* A UDID whose first 8 characters belong to this process alone.
*
* The native-devtools factory really binds `/tmp/argent-nd-<first 8 UDID
* chars>.sock` (getNativeDevtoolsSocketPath), and the bind unlinks and rebinds
* chars>.sock` (`nativeDevtoolsSocketPath`), and the bind unlinks and rebinds
* over whatever already holds that path. A constant UDID therefore makes two
* concurrent runs — a second checkout, a CI matrix cell — silently destroy each
* other's live socket, and the loser reads its own device back as unregistered.
*
* `tail` supplies the remaining UDID groups, which distinguish two devices to
* everything EXCEPT the socket: getNativeDevtoolsSocketPath reads only the
* everything EXCEPT the socket: `nativeDevtoolsSocketPath` reads only the
* first 8 characters, so every UDID from this helper maps to one
* `/tmp/argent-nd-<pid8>.sock`. A file needing two devices that both bind has
* to vary the leading group itself.
Expand Down
104 changes: 104 additions & 0 deletions packages/tool-server/test/list-devices.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,29 @@ vi.mock("../src/utils/ios-device/devicectl", async () => {
};
});

// Whether a provider's simulator is foldable is read from its device set; that
// read is the seam, off by default. The plain listing is wrapped so a test can
// hold it open.
vi.mock("../src/utils/ios-devices", async () => {
const actual = await vi.importActual<typeof import("../src/utils/ios-devices")>(
"../src/utils/ios-devices"
);

return {
...actual,
isFoldableSimulator: vi.fn(async () => false),
listIosSimulators: vi.fn(actual.listIosSimulators),
};
});

import * as fs from "node:fs";
import * as http from "node:http";
import * as os from "node:os";
import * as path from "node:path";
import type { AddressInfo } from "node:net";
import { listDevicesTool } from "../src/tools/devices/list-devices";
import { listIosPhysicalDevices } from "../src/utils/ios-device/devicectl";
import { isFoldableSimulator, listIosSimulators } from "../src/utils/ios-devices";
import {
__resetExternalDeviceCacheForTesting,
__resetProviderWarningsForTesting,
Expand Down Expand Up @@ -664,6 +680,7 @@ describe("list-devices — external provider shadows", () => {
process.env.ARGENT_DEVICE_PROVIDERS = descriptorPath;
__resetExternalDeviceCacheForTesting();
__resetProviderWarningsForTesting();
vi.mocked(isFoldableSimulator).mockClear();
});

afterEach(async () => {
Expand Down Expand Up @@ -703,6 +720,93 @@ describe("list-devices — external provider shadows", () => {
});
});

it("marks a claimed foldable simulator with foldable: true", async () => {
/**
* The provider's device set is not configured, so the plain listing does
* not see the device; the verdict is read from the provider's set.
*/
execFileMock.mockImplementation((cmd: string, args: string[]) => {
if (cmd === "adb" && args[0] === "devices") {
return { stderr: "", stdout: "List of devices attached\n" };
}

return { stderr: "", stdout: "" };
});

vi.mocked(isFoldableSimulator).mockResolvedValueOnce(true);

const result = await listDevicesTool.execute!({}, {});

const claimed = result.devices.find(
(device) => "udid" in device && device.udid === `ext:acme-3f2a9c:${CLAIMED_UDID}`
);

expect(claimed).toMatchObject({ external: true, foldable: true });
expect(isFoldableSimulator).toHaveBeenCalledWith(`ext:acme-3f2a9c:${CLAIMED_UDID}`);
});

it("leaves foldable off a claimed simulator that is not foldable", async () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat, Opus 5.5]: This test does not tell main and this PR apart. With origin/main's list-devices.ts in place, it stays green, and only "marks a claimed foldable simulator with foldable: true" fails.

The PR description names the shadowed simctl row as the first source of the flag, but no test covers a foldable shadowed row. I replaced listed.get(device.nativeId) ?? … (list-devices.ts:280) with an unconditional isFoldableSimulator call, and every list-devices test passes. If that path broke, CI would not notice, either for the flag or for the claim that the extra ~0.2 s applies only when the device set is not configured.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the shadowed-row shortcut, so every lent simulator now goes through the same check. The non-foldable test now asserts that the check ran and a new test covers the timing issue from the other comment.

execFileMock.mockImplementation((cmd: string, args: string[]) => {
if (cmd === "xcrun" && args[0] === "simctl") {
return { stderr: "", stdout: simctlJson() };
}

if (cmd === "adb" && args[0] === "devices") {
return { stderr: "", stdout: "List of devices attached\n" };
}

return { stderr: "", stdout: "" };
});

const result = await listDevicesTool.execute!({}, {});

const claimed = result.devices.find(
(device) => "udid" in device && device.udid === `ext:acme-3f2a9c:${CLAIMED_UDID}`
);

expect(claimed).toBeDefined();
expect(claimed).not.toHaveProperty("foldable");
expect(isFoldableSimulator).toHaveBeenCalledWith(`ext:acme-3f2a9c:${CLAIMED_UDID}`);
});

it("checks foldables while the plain simulator listing is still running", async () => {
/**
* A wedged CoreSimulator holds every `simctl` call until its timeout. The
* foldable check must overlap the plain listing rather than wait for it or
* the two timeouts add up. Here the listing settles only once the check has
* run, so a check that waited for it would hang this test.
*/
let releaseListing: (() => void) | undefined;

vi.mocked(listIosSimulators).mockImplementationOnce(
() =>
new Promise((resolve) => {
releaseListing = () => resolve([]);
})
);

vi.mocked(isFoldableSimulator).mockImplementationOnce(async () => {
releaseListing?.();
return true;
});

execFileMock.mockImplementation((cmd: string, args: string[]) => {
if (cmd === "adb" && args[0] === "devices") {
return { stderr: "", stdout: "List of devices attached\n" };
}

return { stderr: "", stdout: "" };
});

const result = await listDevicesTool.execute!({}, {});

const claimed = result.devices.find(
(device) => "udid" in device && device.udid === `ext:acme-3f2a9c:${CLAIMED_UDID}`
);

expect(claimed).toMatchObject({ external: true, foldable: true });
});

it("leaves an unclaimed simulator in the plain listing", async () => {
execFileMock.mockImplementation((cmd: string, args: string[]) => {
if (cmd === "xcrun" && args[0] === "simctl") {
Expand Down
4 changes: 2 additions & 2 deletions packages/tool-server/test/native-devtools-socket-bind.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const track = (s: net.Server) => {

// A unix socket path must fit sun_path (108 bytes on Linux, 104 on macOS), which
// is why production pins the short `/tmp/argent-nd-<udid8>.sock`
// (getNativeDevtoolsSocketPath). Root the fixtures at the same short base rather
// (`nativeDevtoolsSocketPath`). Root the fixtures at the same short base rather
// than os.tmpdir(): that honors an ambient $TMPDIR, and a long one — easily
// reached by a CI workspace path — pushes these fixtures past the limit and
// fails the bind with EINVAL, testing the host's TMPDIR instead of the code.
Expand Down Expand Up @@ -49,7 +49,7 @@ afterEach(() => {
});

// Production pins the socket under /tmp unconditionally
// (getNativeDevtoolsSocketPath), so this path is POSIX-only by construction
// (`nativeDevtoolsSocketPath`), so this path is POSIX-only by construction
// and SOCK_ROOT above follows it. On win32 there is no /tmp to mkdtemp into.
describe.skipIf(process.platform === "win32")("bindNativeDevtoolsUnixSocket", () => {
it("binds cleanly on a free path", async () => {
Expand Down
Loading
Loading