Skip to content
Merged
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
13 changes: 12 additions & 1 deletion src/daemon/app-log-android.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { AppError } from '../utils/errors.ts';
import { runCmd } from '../utils/exec.ts';
import {
clearPidFile,
readStoredAppLogProcessMeta,
writePidFile,
type AppLogResult,
type AppLogState,
Expand All @@ -21,7 +22,10 @@ export function assertAndroidPackageArgSafe(appBundleId: string): void {
}
}

async function resolveAndroidPid(deviceId: string, appBundleId: string): Promise<string | null> {
export async function resolveAndroidPid(
deviceId: string,
appBundleId: string,
): Promise<string | null> {
const pidResult = await runCmd('adb', ['-s', deviceId, 'shell', 'pidof', appBundleId], {
allowFailure: true,
});
Expand All @@ -30,6 +34,13 @@ async function resolveAndroidPid(deviceId: string, appBundleId: string): Promise
return pid;
}

export function readTrackedAndroidLogcatPid(pidPath: string | undefined): string | null {
const command = readStoredAppLogProcessMeta(pidPath)?.command;
if (!command) return null;
const match = /(?:^|\s)--pid\s+(\d+)(?:\s|$)/.exec(command);
return match?.[1] ?? null;
}

export async function readRecentAndroidLogcatForPackage(
deviceId: string,
appBundleId: string,
Expand Down
11 changes: 11 additions & 0 deletions src/daemon/app-log-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,17 @@ function shouldTerminateStoredProcess(meta: StoredAppLogProcessMeta): boolean {
return true;
}

export function readStoredAppLogProcessMeta(
pidPath: string | undefined,
): StoredAppLogProcessMeta | null {
if (!pidPath || !fs.existsSync(pidPath)) return null;
try {
return parsePidFile(fs.readFileSync(pidPath, 'utf8'));
} catch {
return null;
}
}

export function writePidFile(pidPath: string | undefined, pid: number): void {
if (!pidPath) return;
const dir = path.dirname(pidPath);
Expand Down
66 changes: 56 additions & 10 deletions src/daemon/app-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import { AppError } from '../utils/errors.ts';
import { runCmd } from '../utils/exec.ts';
import {
assertAndroidPackageArgSafe,
readTrackedAndroidLogcatPid,
readRecentAndroidLogcatForPackage,
resolveAndroidPid,
startAndroidAppLog,
} from './app-log-android.ts';
import {
Expand All @@ -14,7 +16,7 @@ import {
startIosSimulatorAppLog,
startMacOsAppLog,
} from './app-log-ios.ts';
import type { AppLogResult, AppLogState } from './app-log-process.ts';
import { APP_LOG_PID_FILENAME, type AppLogResult, type AppLogState } from './app-log-process.ts';
import { waitForChildExit } from './app-log-stream.ts';
import {
mergeNetworkDumps,
Expand Down Expand Up @@ -49,6 +51,11 @@ export type SessionNetworkCapture = {
notes: string[];
};

type AndroidNetworkRecoveryContext = {
reason: 'inactive' | 'stale-active';
trackedPid?: string;
};

type IosSimulatorNetworkRecovery = {
dump: NetworkDump;
recoveredLineCount: number;
Expand Down Expand Up @@ -171,12 +178,13 @@ export async function readSessionNetworkCapture(params: {
});
const notes: string[] = [];

const canRecoverAndroidLogcat =
device.platform === 'android' &&
appLogState !== undefined &&
appLogState !== 'active' &&
Boolean(appBundleId);
if (canRecoverAndroidLogcat) {
const androidRecovery = await resolveAndroidNetworkRecoveryContext({
device,
appBundleId,
appLogPath,
appLogState,
});
if (androidRecovery) {
const recovered = await readRecentAndroidLogcatForPackage(device.id, appBundleId as string);
if (recovered) {
const recoveredDump = readRecentNetworkTrafficFromText(recovered.text, {
Expand All @@ -189,9 +197,7 @@ export async function readSessionNetworkCapture(params: {
});
if (recoveredDump.entries.length > 0) {
dump = mergeNetworkDumps(recoveredDump, dump, maxEntries);
notes.push(
`Session app log stream was inactive. Recovered recent Android HTTP entries from adb logcat for PID set ${recovered.recoveredPids.join(', ')}.`,
);
notes.push(buildAndroidRecoveryNote(androidRecovery, recovered.recoveredPids));
}
}
}
Expand Down Expand Up @@ -248,6 +254,46 @@ export async function readSessionNetworkCapture(params: {
return { backend, dump, notes };
}

async function resolveAndroidNetworkRecoveryContext(params: {
device: DeviceInfo;
appBundleId?: string;
appLogPath: string;
appLogState?: AppLogState;
}): Promise<AndroidNetworkRecoveryContext | null> {
const { device, appBundleId, appLogPath, appLogState } = params;
if (device.platform !== 'android' || !appBundleId) {
return null;
}
if (appLogState !== undefined && appLogState !== 'active') {
return { reason: 'inactive' };
}
if (appLogState !== 'active') {
return null;
}

const trackedPid = readTrackedAndroidLogcatPid(
path.join(path.dirname(appLogPath), APP_LOG_PID_FILENAME),
);
if (!trackedPid) {
return null;
}
const currentPid = await resolveAndroidPid(device.id, appBundleId);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Add timeout to stale-PID probe before network dump

In readSessionNetworkCapture, active Android sessions now run resolveAndroidPid unconditionally to detect stale logcat PID bindings, but that helper runs adb shell pidof without a timeout. If adb transport is wedged (for example, flaky USB/emulator bridge), network dump can hang even though parsing the existing session app.log would have succeeded. This is a regression specific to the new stale-active path; make the PID probe best-effort with a short timeout and skip recovery when it fails.

Useful? React with 👍 / 👎.

if (!currentPid || currentPid === trackedPid) {
return null;
}
return { reason: 'stale-active', trackedPid };
}

function buildAndroidRecoveryNote(
context: AndroidNetworkRecoveryContext,
recoveredPids: string[],
): string {
if (context.reason === 'stale-active') {
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(', ')}.`;
}
return `Session app log stream was inactive. Recovered recent Android HTTP entries from adb logcat for PID set ${recoveredPids.join(', ')}.`;
}

export async function startAppLog(
device: DeviceInfo,
appBundleId: string,
Expand Down
84 changes: 84 additions & 0 deletions src/daemon/handlers/__tests__/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4853,6 +4853,90 @@ test('network dump recovers Android entries from previous package pid in bounded
}
});

test('network dump recovers Android entries when an active stream is still bound to a prior pid', async () => {
const sessionStore = makeSessionStore();
const sessionName = 'android-network-stale-active-pid';
const appLogPath = sessionStore.resolveAppLogPath(sessionName);
const appLogPidPath = sessionStore.resolveAppLogPidPath(sessionName);
fs.mkdirSync(path.dirname(appLogPath), { recursive: true });
fs.writeFileSync(
appLogPath,
'2026-04-01T09:59:00Z GET https://api.example.com/v1/stale status=200\n',
'utf8',
);
fs.writeFileSync(
appLogPidPath,
`${JSON.stringify({
pid: 9999,
startTime: 'Tue Apr 1 09:59:00 2026',
command: 'adb -s emulator-5554 logcat -v time --pid 1234',
})}\n`,
'utf8',
);
sessionStore.set(sessionName, {
...makeSession(sessionName, {
platform: 'android',
id: 'emulator-5554',
name: 'Pixel',
kind: 'emulator',
booted: true,
}),
appBundleId: 'com.example.app',
appLog: {
platform: 'android',
backend: 'android',
outPath: appLogPath,
startedAt: Date.now(),
getState: () => 'active',
stop: async () => {},
wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }),
},
});

mockRunCmd.mockImplementation(async (_cmd, args) => {
if (args.join(' ') === '-s emulator-5554 shell pidof com.example.app') {
return { stdout: '4321\n', stderr: '', exitCode: 0 };
}
if (args.join(' ') === '-s emulator-5554 logcat -d -v time -t 4000') {
return {
stdout:
'04-01 10:00:14.500 I/ActivityManager( 9999): Start proc 4321:com.example.app/u0a123 for top-activity\n' +
'04-01 10:00:15.000 D/GIBSDK (4321): POST https://api.example.com/v1/fresh status=201 duration=15032\n',
stderr: '',
exitCode: 0,
};
}
return { stdout: '', stderr: '', exitCode: 0 };
});

const response = await handleSessionCommands({
req: {
token: 't',
session: sessionName,
command: 'network',
positionals: ['dump', '10', 'summary'],
flags: {},
},
sessionName,
logPath: path.join(os.tmpdir(), 'daemon.log'),
sessionStore,
invoke: noopInvoke,
});

expect(response?.ok).toBe(true);
if (response && response.ok) {
expect(response.data?.path).toContain('adb logcat recovery');
expect(response.data?.state).toBe('active');
const entries = Array.isArray(response.data?.entries) ? response.data.entries : [];
expect(entries.length).toBe(2);
expect((entries[0] as Record<string, unknown>).url).toBe('https://api.example.com/v1/fresh');
expect((entries[1] as Record<string, unknown>).url).toBe('https://api.example.com/v1/stale');
expect(response.data?.notes).toContain(
'Session app log stream was still bound to prior Android PID 1234. Recovered recent Android HTTP entries from adb logcat for PID set 4321.',
);
}
});

test('network dump recovers iOS simulator entries from simctl log show when the live stream is empty', async () => {
const sessionStore = makeSessionStore();
const sessionName = 'ios-network-recovery';
Expand Down
Loading