diff --git a/src/daemon/app-log-android.ts b/src/daemon/app-log-android.ts index b2d63004..1c63d192 100644 --- a/src/daemon/app-log-android.ts +++ b/src/daemon/app-log-android.ts @@ -4,6 +4,7 @@ import { AppError } from '../utils/errors.ts'; import { runCmd } from '../utils/exec.ts'; import { clearPidFile, + readStoredAppLogProcessMeta, writePidFile, type AppLogResult, type AppLogState, @@ -21,7 +22,10 @@ export function assertAndroidPackageArgSafe(appBundleId: string): void { } } -async function resolveAndroidPid(deviceId: string, appBundleId: string): Promise { +export async function resolveAndroidPid( + deviceId: string, + appBundleId: string, +): Promise { const pidResult = await runCmd('adb', ['-s', deviceId, 'shell', 'pidof', appBundleId], { allowFailure: true, }); @@ -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, diff --git a/src/daemon/app-log-process.ts b/src/daemon/app-log-process.ts index b197e8b2..b8ad7f51 100644 --- a/src/daemon/app-log-process.ts +++ b/src/daemon/app-log-process.ts @@ -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); diff --git a/src/daemon/app-log.ts b/src/daemon/app-log.ts index e40fd2e5..5c117b52 100644 --- a/src/daemon/app-log.ts +++ b/src/daemon/app-log.ts @@ -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 { @@ -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, @@ -49,6 +51,11 @@ export type SessionNetworkCapture = { notes: string[]; }; +type AndroidNetworkRecoveryContext = { + reason: 'inactive' | 'stale-active'; + trackedPid?: string; +}; + type IosSimulatorNetworkRecovery = { dump: NetworkDump; recoveredLineCount: number; @@ -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, { @@ -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)); } } } @@ -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 { + 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); + 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, diff --git a/src/daemon/handlers/__tests__/session.test.ts b/src/daemon/handlers/__tests__/session.test.ts index e5bab7c7..af979a76 100644 --- a/src/daemon/handlers/__tests__/session.test.ts +++ b/src/daemon/handlers/__tests__/session.test.ts @@ -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).url).toBe('https://api.example.com/v1/fresh'); + expect((entries[1] as Record).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';