Skip to content

Commit 3eccb5b

Browse files
authored
fix: recover android network dump after stale logcat pid (#363)
1 parent b204c65 commit 3eccb5b

4 files changed

Lines changed: 163 additions & 11 deletions

File tree

src/daemon/app-log-android.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { AppError } from '../utils/errors.ts';
44
import { runCmd } from '../utils/exec.ts';
55
import {
66
clearPidFile,
7+
readStoredAppLogProcessMeta,
78
writePidFile,
89
type AppLogResult,
910
type AppLogState,
@@ -21,7 +22,10 @@ export function assertAndroidPackageArgSafe(appBundleId: string): void {
2122
}
2223
}
2324

24-
async function resolveAndroidPid(deviceId: string, appBundleId: string): Promise<string | null> {
25+
export async function resolveAndroidPid(
26+
deviceId: string,
27+
appBundleId: string,
28+
): Promise<string | null> {
2529
const pidResult = await runCmd('adb', ['-s', deviceId, 'shell', 'pidof', appBundleId], {
2630
allowFailure: true,
2731
});
@@ -30,6 +34,13 @@ async function resolveAndroidPid(deviceId: string, appBundleId: string): Promise
3034
return pid;
3135
}
3236

37+
export function readTrackedAndroidLogcatPid(pidPath: string | undefined): string | null {
38+
const command = readStoredAppLogProcessMeta(pidPath)?.command;
39+
if (!command) return null;
40+
const match = /(?:^|\s)--pid\s+(\d+)(?:\s|$)/.exec(command);
41+
return match?.[1] ?? null;
42+
}
43+
3344
export async function readRecentAndroidLogcatForPackage(
3445
deviceId: string,
3546
appBundleId: string,

src/daemon/app-log-process.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,17 @@ function shouldTerminateStoredProcess(meta: StoredAppLogProcessMeta): boolean {
5454
return true;
5555
}
5656

57+
export function readStoredAppLogProcessMeta(
58+
pidPath: string | undefined,
59+
): StoredAppLogProcessMeta | null {
60+
if (!pidPath || !fs.existsSync(pidPath)) return null;
61+
try {
62+
return parsePidFile(fs.readFileSync(pidPath, 'utf8'));
63+
} catch {
64+
return null;
65+
}
66+
}
67+
5768
export function writePidFile(pidPath: string | undefined, pid: number): void {
5869
if (!pidPath) return;
5970
const dir = path.dirname(pidPath);

src/daemon/app-log.ts

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import { AppError } from '../utils/errors.ts';
55
import { runCmd } from '../utils/exec.ts';
66
import {
77
assertAndroidPackageArgSafe,
8+
readTrackedAndroidLogcatPid,
89
readRecentAndroidLogcatForPackage,
10+
resolveAndroidPid,
911
startAndroidAppLog,
1012
} from './app-log-android.ts';
1113
import {
@@ -14,7 +16,7 @@ import {
1416
startIosSimulatorAppLog,
1517
startMacOsAppLog,
1618
} from './app-log-ios.ts';
17-
import type { AppLogResult, AppLogState } from './app-log-process.ts';
19+
import { APP_LOG_PID_FILENAME, type AppLogResult, type AppLogState } from './app-log-process.ts';
1820
import { waitForChildExit } from './app-log-stream.ts';
1921
import {
2022
mergeNetworkDumps,
@@ -49,6 +51,11 @@ export type SessionNetworkCapture = {
4951
notes: string[];
5052
};
5153

54+
type AndroidNetworkRecoveryContext = {
55+
reason: 'inactive' | 'stale-active';
56+
trackedPid?: string;
57+
};
58+
5259
type IosSimulatorNetworkRecovery = {
5360
dump: NetworkDump;
5461
recoveredLineCount: number;
@@ -171,12 +178,13 @@ export async function readSessionNetworkCapture(params: {
171178
});
172179
const notes: string[] = [];
173180

174-
const canRecoverAndroidLogcat =
175-
device.platform === 'android' &&
176-
appLogState !== undefined &&
177-
appLogState !== 'active' &&
178-
Boolean(appBundleId);
179-
if (canRecoverAndroidLogcat) {
181+
const androidRecovery = await resolveAndroidNetworkRecoveryContext({
182+
device,
183+
appBundleId,
184+
appLogPath,
185+
appLogState,
186+
});
187+
if (androidRecovery) {
180188
const recovered = await readRecentAndroidLogcatForPackage(device.id, appBundleId as string);
181189
if (recovered) {
182190
const recoveredDump = readRecentNetworkTrafficFromText(recovered.text, {
@@ -189,9 +197,7 @@ export async function readSessionNetworkCapture(params: {
189197
});
190198
if (recoveredDump.entries.length > 0) {
191199
dump = mergeNetworkDumps(recoveredDump, dump, maxEntries);
192-
notes.push(
193-
`Session app log stream was inactive. Recovered recent Android HTTP entries from adb logcat for PID set ${recovered.recoveredPids.join(', ')}.`,
194-
);
200+
notes.push(buildAndroidRecoveryNote(androidRecovery, recovered.recoveredPids));
195201
}
196202
}
197203
}
@@ -248,6 +254,46 @@ export async function readSessionNetworkCapture(params: {
248254
return { backend, dump, notes };
249255
}
250256

257+
async function resolveAndroidNetworkRecoveryContext(params: {
258+
device: DeviceInfo;
259+
appBundleId?: string;
260+
appLogPath: string;
261+
appLogState?: AppLogState;
262+
}): Promise<AndroidNetworkRecoveryContext | null> {
263+
const { device, appBundleId, appLogPath, appLogState } = params;
264+
if (device.platform !== 'android' || !appBundleId) {
265+
return null;
266+
}
267+
if (appLogState !== undefined && appLogState !== 'active') {
268+
return { reason: 'inactive' };
269+
}
270+
if (appLogState !== 'active') {
271+
return null;
272+
}
273+
274+
const trackedPid = readTrackedAndroidLogcatPid(
275+
path.join(path.dirname(appLogPath), APP_LOG_PID_FILENAME),
276+
);
277+
if (!trackedPid) {
278+
return null;
279+
}
280+
const currentPid = await resolveAndroidPid(device.id, appBundleId);
281+
if (!currentPid || currentPid === trackedPid) {
282+
return null;
283+
}
284+
return { reason: 'stale-active', trackedPid };
285+
}
286+
287+
function buildAndroidRecoveryNote(
288+
context: AndroidNetworkRecoveryContext,
289+
recoveredPids: string[],
290+
): string {
291+
if (context.reason === 'stale-active') {
292+
return `Session app log stream was still bound to prior Android PID ${context.trackedPid}. Recovered recent Android HTTP entries from adb logcat for PID set ${recoveredPids.join(', ')}.`;
293+
}
294+
return `Session app log stream was inactive. Recovered recent Android HTTP entries from adb logcat for PID set ${recoveredPids.join(', ')}.`;
295+
}
296+
251297
export async function startAppLog(
252298
device: DeviceInfo,
253299
appBundleId: string,

src/daemon/handlers/__tests__/session.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4853,6 +4853,90 @@ test('network dump recovers Android entries from previous package pid in bounded
48534853
}
48544854
});
48554855

4856+
test('network dump recovers Android entries when an active stream is still bound to a prior pid', async () => {
4857+
const sessionStore = makeSessionStore();
4858+
const sessionName = 'android-network-stale-active-pid';
4859+
const appLogPath = sessionStore.resolveAppLogPath(sessionName);
4860+
const appLogPidPath = sessionStore.resolveAppLogPidPath(sessionName);
4861+
fs.mkdirSync(path.dirname(appLogPath), { recursive: true });
4862+
fs.writeFileSync(
4863+
appLogPath,
4864+
'2026-04-01T09:59:00Z GET https://api.example.com/v1/stale status=200\n',
4865+
'utf8',
4866+
);
4867+
fs.writeFileSync(
4868+
appLogPidPath,
4869+
`${JSON.stringify({
4870+
pid: 9999,
4871+
startTime: 'Tue Apr 1 09:59:00 2026',
4872+
command: 'adb -s emulator-5554 logcat -v time --pid 1234',
4873+
})}\n`,
4874+
'utf8',
4875+
);
4876+
sessionStore.set(sessionName, {
4877+
...makeSession(sessionName, {
4878+
platform: 'android',
4879+
id: 'emulator-5554',
4880+
name: 'Pixel',
4881+
kind: 'emulator',
4882+
booted: true,
4883+
}),
4884+
appBundleId: 'com.example.app',
4885+
appLog: {
4886+
platform: 'android',
4887+
backend: 'android',
4888+
outPath: appLogPath,
4889+
startedAt: Date.now(),
4890+
getState: () => 'active',
4891+
stop: async () => {},
4892+
wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }),
4893+
},
4894+
});
4895+
4896+
mockRunCmd.mockImplementation(async (_cmd, args) => {
4897+
if (args.join(' ') === '-s emulator-5554 shell pidof com.example.app') {
4898+
return { stdout: '4321\n', stderr: '', exitCode: 0 };
4899+
}
4900+
if (args.join(' ') === '-s emulator-5554 logcat -d -v time -t 4000') {
4901+
return {
4902+
stdout:
4903+
'04-01 10:00:14.500 I/ActivityManager( 9999): Start proc 4321:com.example.app/u0a123 for top-activity\n' +
4904+
'04-01 10:00:15.000 D/GIBSDK (4321): POST https://api.example.com/v1/fresh status=201 duration=15032\n',
4905+
stderr: '',
4906+
exitCode: 0,
4907+
};
4908+
}
4909+
return { stdout: '', stderr: '', exitCode: 0 };
4910+
});
4911+
4912+
const response = await handleSessionCommands({
4913+
req: {
4914+
token: 't',
4915+
session: sessionName,
4916+
command: 'network',
4917+
positionals: ['dump', '10', 'summary'],
4918+
flags: {},
4919+
},
4920+
sessionName,
4921+
logPath: path.join(os.tmpdir(), 'daemon.log'),
4922+
sessionStore,
4923+
invoke: noopInvoke,
4924+
});
4925+
4926+
expect(response?.ok).toBe(true);
4927+
if (response && response.ok) {
4928+
expect(response.data?.path).toContain('adb logcat recovery');
4929+
expect(response.data?.state).toBe('active');
4930+
const entries = Array.isArray(response.data?.entries) ? response.data.entries : [];
4931+
expect(entries.length).toBe(2);
4932+
expect((entries[0] as Record<string, unknown>).url).toBe('https://api.example.com/v1/fresh');
4933+
expect((entries[1] as Record<string, unknown>).url).toBe('https://api.example.com/v1/stale');
4934+
expect(response.data?.notes).toContain(
4935+
'Session app log stream was still bound to prior Android PID 1234. Recovered recent Android HTTP entries from adb logcat for PID set 4321.',
4936+
);
4937+
}
4938+
});
4939+
48564940
test('network dump recovers iOS simulator entries from simctl log show when the live stream is empty', async () => {
48574941
const sessionStore = makeSessionStore();
48584942
const sessionName = 'ios-network-recovery';

0 commit comments

Comments
 (0)