Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
3 changes: 2 additions & 1 deletion 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 { socketTag } from "../utils/socket-tag";

// Moved to ../utils/ax-prefs; re-exported so existing import paths keep working.
export {
Expand Down Expand Up @@ -160,7 +161,7 @@ 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`;
return `/tmp/ax-${socketTag(udid)}.sock`;

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]: No test checks that this call site, or getNativeDevtoolsSocketPath (native-devtools.ts:510), uses socketTag. I reverted each one to udid.slice(0, 8) and ran 17 test files (372 tests): socket-tag, list-devices, the ax-service, native-devtools, external-devices/attach and foldable files. All tests pass. socket-tag.test.ts tests only the helper. A refactor that drops the call would bring back the shared /tmp/ax-ext:rado.sock for two lent devices, and CI would stay green.

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.

Both socket paths now come from small helpers in socket-tag.ts (axServiceSocketPath, nativeDevtoolsSocketPath), the blueprints call them and socket-tag.test.ts tests them.

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]: Still present as of 4b62d5c for the ax-service call site. I reverted ax-service.ts:345 to `/tmp/ax-${udid.slice(0, 8)}.sock` and ran the full tool-server suite in 6 shards. Every shard passed, with the same counts as the unmutated head. socket-tag.test.ts calls only the helpers, and every test that reaches the factory uses a plain UDID, where the old and new spellings agree. Reverting the native-devtools call site has no reachable effect, because a claimed ext: device always attaches to the provider's lentSocketPath.

}

interface PendingRpc {
Expand Down
9 changes: 6 additions & 3 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 { socketTag } from "../utils/socket-tag";
import {
pickIosHost,
buildDyldInsertLibraries,
Expand Down Expand Up @@ -502,9 +503,11 @@ interface AppConnection {
}

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`;
/**
* Deterministic and short, at most 32 chars, well under the 104-char macOS
* Unix socket limit.
*/
return `/tmp/argent-nd-${socketTag(udid)}.sock`;
}

/**
Expand Down
43 changes: 40 additions & 3 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,31 @@ function toListedExternal(device: ExternalDevice): ExternalListedDevice {
};
}

/**
* Whether each external device is a foldable simulator. The plain simctl row a
* provider's simulator shadows (its device set is in
* `ios.additionalDeviceSets`) already carries the verdict; otherwise it is read
* from the provider's device set, memoized per UDID. A foldable only differs

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]: "memoized per UDID" holds only when the listing finds the device, because isFoldableSimulator caches only if (found) (ios-devices.ts:283-285). Take a lent simulator whose declared device set does not list its nativeId: a stale descriptor after the simulator was deleted, or a device simctl reports as isAvailable: false. For each such device, every list-devices call runs one more xcrun simctl --set <set> list devices --json after the fan-out.

I measured this with a fake provider lending 2 simulators from an existing but empty set, over 3 calls. Merge-base: 1001, 474 and 469 ms, with no --set spawns. This head: 1152, 905 and 899 ms, with 6 --set spawns (2 on every call). The PR description says only the first call is slower, which does not hold in this case.

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.

The lookup now runs in parallel with the other discovery branches, so a miss no longer makes list-devices slower. Misses are kept uncached on purpose, because a device that the set does not list yet should still get the flag once it shows up. This only happens when a provider lists a device that is missing from its own set, which is a provider bug. Updated the PR description.

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]: Still present as of 4b62d5c. A lent simulator that its set does not list still spawns simctl --set <set> list devices on every call: 5 calls gave 5 --set spawns. That is one per lent simulator per call, with no dedup when several share a set, so "one simctl call per lent simulator per tool-server run" in the description does not hold for them.

"Adds no time" holds only while another branch is slower. With an empty default set, the external branch is the slowest one:

  • Every simctl call taking 1.5 s: call 1 took 1521 ms on 91542bd and 3062 ms on this head, because the set listing and the devicetypes read run in series after the probe.
  • Real simctl, two lent simulators from an empty existing set: steady-state calls took 504-518 ms here and 410-483 ms on 91542bd.

* in which panel shows the UI, so an agent needs the flag to know `fold`
* applies, whoever drives the device.
*/
async function externalFoldables(
external: readonly ExternalDevice[],
ios: readonly IosSimulator[]
): Promise<boolean[]> {
const listed = new Map(ios.map((sim) => [sim.udid, sim.foldable === true]));

return Promise.all(
external.map(async (device) => {
if (device.platform !== "ios" || device.kind !== "simulator") return false;

return (
listed.get(device.nativeId) ?? (await isFoldableSimulator(device.id).catch(() => false))
);
})
);
}

/**
* Native ids a provider is currently claiming.
*
Expand Down Expand Up @@ -343,6 +374,12 @@ Booted/ready devices are listed first. Platforms whose CLI is unavailable are si

const externalShadows = externalShadowIds(external);

const externalFoldable = await withDeadline(
externalFoldables(external, ios),
external.map(() => false),
"external-foldable"
);

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 step starts only after the whole Promise.all fan-out has settled, and it has a second BRANCH_DEADLINE_MS backstop of its own. Take a wedged CoreSimulator while a provider lends an iOS simulator. The ios branch times out to [], so listed misses, and each lent simulator gets a second simctl list devices that also runs to its 10 s timeout. A miss is never memoized, so this happens on every call, not just the first.

I reproduced it in-process: an esbuild bundle of listDevicesTool, a fake provider lending 2 simulators, and an xcrun shim that hangs on any simctl call. Over two calls, merge-base 91542bd took 10005 ms and 10005 ms with 2 simctl spawns. This head took 20010 ms and 20006 ms with 6 spawns. The 25 s backstop on the fan-out no longer bounds the tool. list-devices-deadline.test.ts checks single branches only and does not account for this sequential step.

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.

Good catch, the foldable check now runs inside the external discovery branch. With a hung simctl, the wait is one timeout again. A test now checks that the foldable check does not wait for the plain listing.


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

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

/**
* The per-device part of a `/tmp/<prefix>-<tag>.sock` path.
*
* A plain UDID keeps its first 8 characters, the name these sockets have
* always had. Any other id (an external provider's `ext:<provider>:<udid>`)
* would collapse to its shared prefix that way, so every device of a provider
* got one socket; it gets 12 hex characters of its SHA-256 instead, which

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 comment describes the change rather than the current code: "the name these sockets have always had" and "would collapse to its shared prefix that way, so every device of a provider got one socket". A reader who never saw the old slice(0, 8) cannot tell what got one socket. The scope is also inaccurate. The old tag of an ext: id was ext: plus the first 4 characters of the provider id, so it was shared by every provider whose id starts with the same 4 characters, not only by the devices of one provider.

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.

Rewrote the comment to describe the current code, with the correct scope.

* 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);
}
59 changes: 59 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,24 @@ vi.mock("../src/utils/ios-device/devicectl", async () => {
};
});

// Whether a provider's simulator is foldable is read from its device set when
// the plain listing does not carry it; that read is the seam, off by default.
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) };
});

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 } from "../src/utils/ios-devices";
import {
__resetExternalDeviceCacheForTesting,
__resetProviderWarningsForTesting,
Expand Down Expand Up @@ -703,6 +714,54 @@ 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 and 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");
});

it("leaves an unclaimed simulator in the plain listing", async () => {
execFileMock.mockImplementation((cmd: string, args: string[]) => {
if (cmd === "xcrun" && args[0] === "simctl") {
Expand Down
25 changes: 25 additions & 0 deletions packages/tool-server/test/socket-tag.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, it, expect } from "vitest";
import { socketTag } from "../src/utils/socket-tag";

describe("socketTag", () => {
it("keeps the first 8 characters of a plain UDID, the existing socket name", () => {
expect(socketTag("0A1B2C3D-1111-2222-3333-444444444444")).toBe("0A1B2C3D");
});

it("gives two devices of one external provider distinct tags", () => {
const a = socketTag("ext:radon-1a2b3c:0A1B2C3D-1111-2222-3333-444444444444");
const b = socketTag("ext:radon-1a2b3c:9F8E7D6C-1111-2222-3333-444444444444");
expect(a).not.toBe(b);
expect(a).not.toBe("ext:rado");
});

it("is stable, short, and filename-safe for a non-UDID id", () => {
const id = "ext:radon-1a2b3c:0A1B2C3D-1111-2222-3333-444444444444";
expect(socketTag(id)).toBe(socketTag(id));
expect(socketTag(id)).toMatch(/^[0-9a-f]{12}$/);
});

it("cannot collide with a UDID tag, which is 8 characters long", () => {
expect(socketTag("ext:x:0A1B2C3D")).toHaveLength(12);
});
});
Loading