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
15 changes: 15 additions & 0 deletions packages/tool-server/src/blueprints/native-devtools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,12 @@ export interface NativeDevtoolsApi {
* relaunch.
*/
activateNetworkInspection(bundleId: string): void;
/**
* True once per gap: capture for this app was turned on while it was already
* running, so the requests before that (its launch among them) were missed.
* Reading it clears it, so only the answer that reports the log says so.
*/
takeCaptureGap(bundleId: string): boolean;
getNetworkLog(bundleId: string): NetworkEvent[];
clearNetworkLog(bundleId: string): void;
getAppState(bundleId: string): Promise<NativeAppState>;
Expand Down Expand Up @@ -665,6 +671,7 @@ export const nativeDevtoolsBlueprint: ServiceBlueprint<NativeDevtoolsApi, Device
let envRetired = false;

const activatedBundleIds = new Set<string>();
const captureGaps = new Set<string>();
const events = new TypedEventEmitter<ServiceEvents>();

const noteInitFailure = (err: unknown): void => {
Expand Down Expand Up @@ -811,6 +818,9 @@ export const nativeDevtoolsBlueprint: ServiceBlueprint<NativeDevtoolsApi, Device
connections.set(bundleId, { socket, networkLog: [] });

if (activatedBundleIds.has(bundleId)) {
// Our own listener hears an app at its launch, so nothing was
// missed. A lent agent is an app that was already running.
if (lentSocketPath) captureGaps.add(bundleId);
socket.write(
JSON.stringify({
type: "Control",
Expand Down Expand Up @@ -1071,9 +1081,11 @@ export const nativeDevtoolsBlueprint: ServiceBlueprint<NativeDevtoolsApi, Device
},

activateNetworkInspection(bundleId) {
const startedNow = !activatedBundleIds.has(bundleId);
activatedBundleIds.add(bundleId);
const conn = connections.get(bundleId);
if (conn) {
if (startedNow) captureGaps.add(bundleId);
conn.socket.write(
JSON.stringify({
type: "Control",
Expand All @@ -1083,6 +1095,8 @@ export const nativeDevtoolsBlueprint: ServiceBlueprint<NativeDevtoolsApi, Device
}
},

takeCaptureGap: (bundleId) => captureGaps.delete(bundleId),

getNetworkLog: (bundleId) => [...(connections.get(bundleId)?.networkLog ?? [])],

clearNetworkLog: (bundleId) => {
Expand Down Expand Up @@ -1156,6 +1170,7 @@ export const nativeDevtoolsBlueprint: ServiceBlueprint<NativeDevtoolsApi, Device
}
connections.clear();
activatedBundleIds.clear();
captureGaps.clear();
/**
* Attach mode owns only its end of the connection: hang up and leave
* the provider's socket file and server exactly as they were.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ const zodSchema = z.object({
});

type Params = z.infer<typeof zodSchema>;
type Result = NativeDevtoolsPrecheckBlock | { status: "ok"; count: number; events: NetworkEvent[] };
type Result =
| NativeDevtoolsPrecheckBlock
| { status: "ok"; count: number; events: NetworkEvent[]; note?: string };

export const nativeNetworkLogsTool: ToolDefinition<Params, Result> = {
id: "native-network-logs",
Expand All @@ -37,6 +39,7 @@ export const nativeNetworkLogsTool: ToolDefinition<Params, Result> = {
Unlike the JS-level network inspector (view-network-logs), this captures ALL network traffic from the app including native modules, Swift/Objective-C networking, and background transfers that never pass through JS XMLHttpRequest or fetch.
Use when you need to inspect native-level HTTP traffic that is invisible to the JS-level capture.
Returns { status, count, events } where each event contains URL, method, status code, headers, and timing.
Capture for an app starts on the first call for it, and stays on across relaunches (except on a device whose devtools agent argent attached to, where a relaunch ends it). So the first ok answer after capture started for an app that was already running carries a note that requests made before that, including the app's launch, were not recorded, so count 0 there does not mean the app made no requests. To capture the launch, restart-app and call again, unless the note says the launch cannot be recorded on that device.
If status is restart_required: follow the message (usually restart-app), then retry. If status is service_stale: the app is already injected, so restarting it cannot help. Restart the tool-server (\`argent server stop && argent server start --detach\`) and retry. If the same status comes back after that restart, stop restarting: follow the message, which names the terminal fallback. If status is connect_pending: the app is injected and still connecting. Do not restart it, wait a few seconds and retry. If status is init_failed: the simulator's native-devtools environment could not be initialised. Follow the message (re-boot the simulator) rather than retrying this tool.
A not-running app comes back as one of those statuses rather than a failure. An app that is not connected does too, except on a device whose devtools agent argent attached to rather than armed itself: there it fails with \`NATIVE_DEVTOOLS_NOT_CONNECTED\`, since no restart of ours can complete a handshake we do not own. Failures are separate: an Apple system app is rejected outright (terminal: never retry it), while a missing host dependency or a udid that is not an Apple device is not.`,
zodSchema,
Expand All @@ -50,6 +53,7 @@ A not-running app comes back as one of those statuses rather than a failure. An
const api = services.nativeDevtools as NativeDevtoolsApi;

const blocked = await precheckNativeDevtools(api, params.udid, params.bundleId);
api.activateNetworkInspection(params.bundleId);

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]: Activation now runs before the NATIVE_DEVTOOLS_NOT_CONNECTED throw below, so on a provider-attached device that error message is false: it says "No network log is being captured for it", but this call has just armed capture. In a harness with a real Registry, the real blueprint and a fake provider socket, the first call after a cold attach threw that message. The provider's handshake then replayed the activation, and the next call returned ok with the note. After a provider hang-up, the first call threw the same message, and a request the app sent after that throw was still in the next answer (count: 1). The tool description (line 44) also presents this error as final on these devices, so an agent that trusts it gives up on a capture that is actually running. Why the first call throws at all predates this PR, filed as #1561.

if (blocked) return blocked;

/**
Expand All @@ -69,10 +73,21 @@ A not-running app comes back as one of those statuses rather than a failure. An
);
}

api.activateNetworkInspection(params.bundleId);

const events = api.getNetworkLog(params.bundleId).slice(-params.limit);
if (params.clear) api.clearNetworkLog(params.bundleId);
return { status: "ok", count: events.length, events };
return {
status: "ok",
count: events.length,
events,
...(api.takeCaptureGap(params.bundleId) && {
note:
`Network capture for ${params.bundleId} started while the app was already running. ` +
`Requests made before that, including the app's launch, were not recorded. ` +
(api.armsEnv
? `To capture the launch, restart-app and call again.`
: `On this device the app's lifecycle belongs to its provider and a relaunch ends ` +
`capture, so its launch cannot be recorded here. Do not restart the app for it.`),
}),
};
},
};
1 change: 1 addition & 0 deletions packages/tool-server/test/describe-tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ function makeNativeDevtoolsApi(options: {
appConnectionState: async () =>
options.state ?? (options.requiresRestart ? "stale_process" : "connected"),
activateNetworkInspection: () => {},
takeCaptureGap: () => false,
getNetworkLog: () => [],
clearNetworkLog: () => {},
getAppState: async (bundleId) => ({
Expand Down
48 changes: 46 additions & 2 deletions packages/tool-server/test/external-native-devtools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,18 +39,23 @@ let temporaryDirectory: string;
type LentAgent = {
close: () => Promise<void>;
hangUp: () => void;
/** Sends the deferred `Control` frame of a `deferHandshake` agent. */
handshake: () => void;
received: Array<{ type: string; payload: Record<string, unknown> }>;
socketPath: string;
};

async function startLentAgent(): Promise<LentAgent> {
async function startLentAgent(options: { deferHandshake?: boolean } = {}): Promise<LentAgent> {
const socketPath = path.join(temporaryDirectory, "agent.sock");
const received: LentAgent["received"] = [];
const sockets: net.Socket[] = [];

const sendHandshake = (socket: net.Socket) =>
socket.write(JSON.stringify({ payload: { bundleId: BUNDLE_ID }, type: "Control" }) + "\n");

const server = net.createServer((socket) => {
sockets.push(socket);
socket.write(JSON.stringify({ payload: { bundleId: BUNDLE_ID }, type: "Control" }) + "\n");
if (!options.deferHandshake) sendHandshake(socket);

readline.createInterface({ input: socket }).on("line", (raw) => {
const message = JSON.parse(raw);
Expand All @@ -76,6 +81,9 @@ async function startLentAgent(): Promise<LentAgent> {
hangUp: () => {
for (const socket of sockets.splice(0)) socket.destroy();
},
handshake: () => {
for (const socket of sockets) sendHandshake(socket);
},
received,
socketPath,
};
Expand Down Expand Up @@ -243,6 +251,42 @@ describe("a provider that lends its native-devtools agent", () => {
await agent.close();
});

/**
* The lent app was running before we attached, so its launch is missed. The
* note must say so on the first ok answer, even when an earlier call failed
* because the handshake had not landed, and must not send the agent to
* restart an app whose relaunch ends capture here.
*/
it("notes the missed launch on the first ok answer without advising a restart", async () => {
const agent = await startLentAgent({ deferHandshake: true });
publishDescriptor({ socketPath: agent.socketPath });

const instance = await instantiate();
const { nativeNetworkLogsTool } =
await import("../src/tools/native-devtools/native-network-logs");
const call = () =>
nativeNetworkLogsTool.execute(
{ nativeDevtools: instance.api },
{ bundleId: BUNDLE_ID, clear: false, limit: 50, udid: DEVICE_ID }
);

await expect(call()).rejects.toThrow(/not connected/);

agent.handshake();
await waitForConnectedBundle(instance.api);

const first = (await call()) as { status: string; note?: string };
expect(first.status).toBe("ok");
expect(first.note).toMatch(/were not recorded/);
expect(first.note).not.toMatch(/restart-app/);

const second = (await call()) as { note?: string };
expect(second.note).toBeUndefined();

await instance.dispose();
await agent.close();
});

/**
* We are the client on this path, so nothing re-dials on its own. Without a
* terminated signal the registry would keep serving this instance and its
Expand Down
1 change: 1 addition & 0 deletions packages/tool-server/test/ios-launch-args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ function makeNativeApi(): NativeDevtoolsApi {
listConnectedBundleIds: () => [],
appConnectionState: async () => "connected",
activateNetworkInspection: () => {},
takeCaptureGap: () => false,
getNetworkLog: () => [],
clearNetworkLog: () => {},
getAppState: async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ function makeNativeApi(): NativeDevtoolsApi {
listConnectedBundleIds: () => [],
appConnectionState: async () => "connected",
activateNetworkInspection: () => {},
takeCaptureGap: () => false,
getNetworkLog: () => [],
clearNetworkLog: () => {},
getAppState: async () => {
Expand Down
1 change: 1 addition & 0 deletions packages/tool-server/test/native-devtools-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ function makeNativeApi(options: {
return options.state ?? (options.appRunning ? "stale_process" : "not_running");
},
activateNetworkInspection: () => {},
takeCaptureGap: () => false,
getNetworkLog: () => [],
clearNetworkLog: () => {},
getAppState: async () => {
Expand Down
78 changes: 78 additions & 0 deletions packages/tool-server/test/native-network-logs-activation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

vi.mock("../src/blueprints/native-devtools", async () => {
const actual = await vi.importActual<typeof import("../src/blueprints/native-devtools")>(
"../src/blueprints/native-devtools"
);
return {
...actual,
precheckNativeDevtools: vi.fn(async () => ({
status: "restart_required",
message: "restart the app",
})),
};
});

const UDID = "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA";

describe("native-network-logs before the app is instrumented", () => {
beforeEach(async () => {
const { __resetDepCacheForTests, __primeDepCacheForTests } =
await import("../src/utils/check-deps");
__resetDepCacheForTests();
__primeDepCacheForTests(["xcrun"]);
});

it("registers network capture before answering restart_required", async () => {
const activateNetworkInspection = vi.fn();
const api = { activateNetworkInspection } as any;
const { nativeNetworkLogsTool } =
await import("../src/tools/native-devtools/native-network-logs");

const result = await nativeNetworkLogsTool.execute(
{ nativeDevtools: api },
{ udid: UDID, bundleId: "com.example.app", limit: 50, clear: false }
);

expect(result).toMatchObject({ status: "restart_required" });
// So the relaunched app starts capturing at its handshake.
expect(activateNetworkInspection).toHaveBeenCalledWith("com.example.app");
Comment thread
blazejkustra marked this conversation as resolved.
});

it("keeps the missed-requests note for the first ok answer", async () => {
const { precheckNativeDevtools } = await import("../src/blueprints/native-devtools");
// Blocked once (init_failed after activation reached a live connection), then through.
vi.mocked(precheckNativeDevtools)
.mockResolvedValueOnce({ status: "init_failed", message: "re-boot" } as any)
.mockResolvedValueOnce(null);
let gap = false;
const api = {
activateNetworkInspection: vi.fn(() => {
gap = true;
}),
takeCaptureGap: vi.fn(() => {
const had = gap;
gap = false;
return had;
}),
isConnected: () => true,
getNetworkLog: () => [],
armsEnv: true,
} as any;
const { nativeNetworkLogsTool } =
await import("../src/tools/native-devtools/native-network-logs");
const params = { udid: UDID, bundleId: "com.example.app", limit: 50, clear: false };

expect(await nativeNetworkLogsTool.execute({ nativeDevtools: api }, params)).toMatchObject({
status: "init_failed",
});
expect(api.takeCaptureGap).not.toHaveBeenCalled();

const ok = (await nativeNetworkLogsTool.execute({ nativeDevtools: api }, params)) as {
status: string;
note?: string;
};
expect(ok.status).toBe("ok");
expect(ok.note).toMatch(/were not recorded.*restart-app/);
});
});
Loading
Loading