From defd554a90b9add9a4537ac9893d48a70c7c1d04 Mon Sep 17 00:00:00 2001 From: Adam Rowe Date: Fri, 23 Oct 2026 22:22:39 -0400 Subject: [PATCH 1/5] fix(android): bound the dumpsys reads behind dialog and foreground checks The blocking-dialog and foreground observations ran `adb shell dumpsys` with no timeout before and after every Android interaction, so a hung read waited for the client envelope and reset the daemon. Each dump read now carries a 10 s bound. A timed-out read counts as unanswered, the same `unknown` a read that printed nothing produces, so a slow probe still never refuses the command; the reader then skips its remaining variants, so one observation costs at most one bound. This unblocks the longpress envelope ledger in #3326, which cannot prove an envelope while these reads are unbounded. Co-Authored-By: Claude Opus 5.5 --- packages/contracts/src/android-observation.ts | 2 +- .../platform-android/src/observation.test.ts | 68 +++++++++++++++++++ packages/platform-android/src/observation.ts | 30 ++++++-- 3 files changed, 94 insertions(+), 6 deletions(-) diff --git a/packages/contracts/src/android-observation.ts b/packages/contracts/src/android-observation.ts index 82080d1ce9..b267d9f3b1 100644 --- a/packages/contracts/src/android-observation.ts +++ b/packages/contracts/src/android-observation.ts @@ -27,7 +27,7 @@ export type AndroidObservationHost = Readonly<{ runAdb( device: DeviceInfo, args: readonly string[], - options?: Readonly<{ allowFailure?: boolean }>, + options?: Readonly<{ allowFailure?: boolean; timeoutMs?: number }>, ): Promise; readSnapshotNodes(device: DeviceInfo): Promise; openApp(device: DeviceInfo, appBundleId: string): Promise; diff --git a/packages/platform-android/src/observation.test.ts b/packages/platform-android/src/observation.test.ts index 0a0ab3cb91..ea6441ae35 100644 --- a/packages/platform-android/src/observation.test.ts +++ b/packages/platform-android/src/observation.test.ts @@ -1,6 +1,7 @@ import { expect, test, vi } from 'vitest'; import type { AndroidObservationHost } from '@agent-device/contracts/android-observation'; import type { DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; import { createAndroidObservationAdapter } from './observation.ts'; const device = { @@ -98,3 +99,70 @@ test('a transient empty dump never demotes a variant that previously answered', expect(calls[thirdReadStart]).toBe('shell dumpsys window windows'); }); + +function timedOutAdb(args: readonly string[]): AppError { + return new AppError('COMMAND_FAILED', 'adb timed out', { cmd: 'adb', args, timeoutMs: 1 }); +} + +test('bounds every dump read the foreground question makes', async () => { + const host = hostFor(new Map()); + const observer = createAndroidObservationAdapter(host); + + await observer.readAppState({ ...device, id: 'emulator-bounded' }); + + const calls = vi.mocked(host.runAdb).mock.calls; + expect(calls.map(([, args]) => args.join(' '))).toEqual([ + 'shell dumpsys window windows', + 'shell dumpsys window', + 'shell dumpsys activity activities', + 'shell dumpsys activity', + ]); + for (const [, , options] of calls) { + expect(options).toEqual({ allowFailure: true, timeoutMs: expect.any(Number) }); + expect(options?.timeoutMs).toBeGreaterThan(0); + } +}); + +test.each([ + { + question: 'foreground', + ask: (observer: ReturnType, target: DeviceInfo) => + observer.readAppState(target), + unanswered: {}, + }, + { + question: 'blocking dialog', + ask: (observer: ReturnType, target: DeviceInfo) => + observer.readBlockingDialog(target), + unanswered: { status: 'unknown' }, + }, +])( + 'a timed-out dump leaves the $question question unanswered and reads no further variant', + async ({ question, ask, unanswered }) => { + const host: AndroidObservationHost = { + ...hostFor(new Map()), + runAdb: vi.fn(async (_device, args) => { + throw timedOutAdb(args); + }), + }; + + await expect( + ask(createAndroidObservationAdapter(host), { ...device, id: `emulator-hung-${question}` }), + ).resolves.toEqual(unanswered); + expect(host.runAdb).toHaveBeenCalledTimes(1); + }, +); + +test('a dump that fails without timing out still fails the observation', async () => { + const failure = new AppError('COMMAND_FAILED', 'adb: device offline', { cmd: 'adb' }); + const host: AndroidObservationHost = { + ...hostFor(new Map()), + runAdb: vi.fn(async () => { + throw failure; + }), + }; + + await expect( + createAndroidObservationAdapter(host).readAppState({ ...device, id: 'emulator-offline' }), + ).rejects.toBe(failure); +}); diff --git a/packages/platform-android/src/observation.ts b/packages/platform-android/src/observation.ts index 8a421e085c..442fd9311d 100644 --- a/packages/platform-android/src/observation.ts +++ b/packages/platform-android/src/observation.ts @@ -5,6 +5,7 @@ import type { } from '@agent-device/contracts/android-observation'; import type { AppStateRuntimeResult } from '@agent-device/contracts/app-state-runtime'; import type { DeviceInfo } from '@agent-device/kernel/device'; +import { isCommandTimeoutError } from '@agent-device/host-kit/command'; import { deviceShellArgv } from '@agent-device/kernel/device-shell'; import { AppError } from '@agent-device/kernel/errors'; import { @@ -32,6 +33,8 @@ const PERMISSION_PACKAGES = new Set([ 'com.google.android.packageinstaller', ]); +const DUMP_READ_TIMEOUT_MS = 10_000; + type Question = 'foreground' | 'blockingDialog'; type DumpReader = (words: readonly string[]) => Promise; const everAnswered = new Map>(); @@ -79,18 +82,35 @@ export function createAndroidObservationAdapter( }); } +/** + * A dump that times out is an unanswered read, like one that printed nothing: the observation + * reports `unknown` and never refuses the command. After one timeout the device is not answering, + * so the reader skips its remaining variants and an observation costs at most one bound. + */ function createDumpReader(host: AndroidObservationHost, device: DeviceInfo): DumpReader { const dumps = new Map>(); + let timedOut = false; return (words) => { + if (timedOut) return Promise.resolve(''); const key = words.join(' '); const pending = dumps.get(key) ?? host - .runAdb(device, deviceShellArgv('adb', 'shell', words), { allowFailure: true }) - .then((result) => { - recordSections(device, key, result.stdout); - return result.stdout; - }); + .runAdb(device, deviceShellArgv('adb', 'shell', words), { + allowFailure: true, + timeoutMs: DUMP_READ_TIMEOUT_MS, + }) + .then( + (result) => { + recordSections(device, key, result.stdout); + return result.stdout; + }, + (error: unknown) => { + if (!isCommandTimeoutError(error)) throw error; + timedOut = true; + return ''; + }, + ); dumps.set(key, pending); return pending; }; From 8a582e78c3fa081ef956244a143a1b0df01bb1bc Mon Sep 17 00:00:00 2001 From: Adam Rowe Date: Fri, 23 Oct 2026 23:30:07 -0400 Subject: [PATCH 2/5] fix(android): share one dump budget across an observation's variants A per-read bound still let one observation walk every dumpsys variant that answered without a focus line, each spending up to its own 10 s. All reads of one observation now draw on a single 10 s budget, so an observation costs at most one bound however many variants it reads; a spent budget or a timed-out read leaves the question unanswered. Co-Authored-By: Claude Opus 5.5 --- .../platform-android/src/observation.test.ts | 122 ++++++++++++------ packages/platform-android/src/observation.ts | 49 +++---- 2 files changed, 110 insertions(+), 61 deletions(-) diff --git a/packages/platform-android/src/observation.test.ts b/packages/platform-android/src/observation.test.ts index ea6441ae35..c20e807ff6 100644 --- a/packages/platform-android/src/observation.test.ts +++ b/packages/platform-android/src/observation.test.ts @@ -100,55 +100,103 @@ test('a transient empty dump never demotes a variant that previously answered', expect(calls[thirdReadStart]).toBe('shell dumpsys window windows'); }); -function timedOutAdb(args: readonly string[]): AppError { - return new AppError('COMMAND_FAILED', 'adb timed out', { cmd: 'adb', args, timeoutMs: 1 }); +function timedOutAdb(args: readonly string[], timeoutMs: number): AppError { + return new AppError('COMMAND_FAILED', 'adb timed out', { cmd: 'adb', args, timeoutMs }); } -test('bounds every dump read the foreground question makes', async () => { - const host = hostFor(new Map()); - const observer = createAndroidObservationAdapter(host); +type Observer = ReturnType; - await observer.readAppState({ ...device, id: 'emulator-bounded' }); +const QUESTIONS = [ + { + question: 'foreground', + ask: (observer: Observer, target: DeviceInfo) => observer.readAppState(target), + unanswered: {}, + }, + { + question: 'blocking dialog', + ask: (observer: Observer, target: DeviceInfo) => observer.readBlockingDialog(target), + unanswered: { status: 'unknown' }, + }, +] as const; + +/** + * A host whose every dump answers without a focus line after `spendMs(timeoutMs)` of a virtual + * clock, optionally timing out instead, so an observation walks every variant it is allowed to. + */ +function slowHost(params: { spendMs: (timeoutMs: number) => number; timeOut?: boolean }): { + host: AndroidObservationHost; + timeouts: number[]; + restore: () => void; +} { + const originMs = Date.now(); + let elapsedMs = 0; + const now = vi.spyOn(Date, 'now').mockImplementation(() => originMs + elapsedMs); + const timeouts: number[] = []; + const host: AndroidObservationHost = { + ...hostFor(new Map()), + runAdb: vi.fn(async (_device, args, options) => { + const timeoutMs = options?.timeoutMs ?? Number.POSITIVE_INFINITY; + timeouts.push(timeoutMs); + elapsedMs += params.spendMs(timeoutMs); + if (params.timeOut) throw timedOutAdb(args, timeoutMs); + return { exitCode: 0, stdout: '', stderr: '' }; + }), + }; + return { host, timeouts, restore: () => now.mockRestore() }; +} - const calls = vi.mocked(host.runAdb).mock.calls; - expect(calls.map(([, args]) => args.join(' '))).toEqual([ +test('bounds every dump read the foreground question makes', async () => { + const { host, timeouts, restore } = slowHost({ spendMs: () => 0 }); + try { + await createAndroidObservationAdapter(host).readAppState({ ...device, id: 'emulator-bounded' }); + } finally { + restore(); + } + + expect(vi.mocked(host.runAdb).mock.calls.map(([, args]) => args.join(' '))).toEqual([ 'shell dumpsys window windows', 'shell dumpsys window', 'shell dumpsys activity activities', 'shell dumpsys activity', ]); - for (const [, , options] of calls) { - expect(options).toEqual({ allowFailure: true, timeoutMs: expect.any(Number) }); - expect(options?.timeoutMs).toBeGreaterThan(0); + for (const timeoutMs of timeouts) expect(Number.isFinite(timeoutMs) && timeoutMs > 0).toBe(true); +}); + +test('later variants receive only what the earlier reads left of one observation budget', async () => { + const { host, timeouts, restore } = slowHost({ + spendMs: (timeoutMs) => Math.floor(timeoutMs / 2), + }); + try { + await createAndroidObservationAdapter(host).readAppState({ ...device, id: 'emulator-slow' }); + } finally { + restore(); + } + + const budgetMs = timeouts[0]!; + expect(timeouts.length).toBeGreaterThan(1); + for (let index = 1; index < timeouts.length; index += 1) { + expect(timeouts[index]).toBe(timeouts[index - 1]! - Math.floor(timeouts[index - 1]! / 2)); } + const spentMs = timeouts.reduce((sum, timeoutMs) => sum + Math.floor(timeoutMs / 2), 0); + expect(spentMs).toBeLessThanOrEqual(budgetMs); }); -test.each([ - { - question: 'foreground', - ask: (observer: ReturnType, target: DeviceInfo) => - observer.readAppState(target), - unanswered: {}, - }, - { - question: 'blocking dialog', - ask: (observer: ReturnType, target: DeviceInfo) => - observer.readBlockingDialog(target), - unanswered: { status: 'unknown' }, - }, -])( - 'a timed-out dump leaves the $question question unanswered and reads no further variant', - async ({ question, ask, unanswered }) => { - const host: AndroidObservationHost = { - ...hostFor(new Map()), - runAdb: vi.fn(async (_device, args) => { - throw timedOutAdb(args); - }), - }; - - await expect( - ask(createAndroidObservationAdapter(host), { ...device, id: `emulator-hung-${question}` }), - ).resolves.toEqual(unanswered); +test.each( + QUESTIONS.flatMap((entry) => [ + { ...entry, ending: 'times out', timeOut: true }, + { ...entry, ending: 'answers at its bound', timeOut: false }, + ]), +)( + 'a $question read that $ending spends the observation budget and leaves it unanswered', + async ({ question, ask, unanswered, timeOut }) => { + const { host, restore } = slowHost({ spendMs: (timeoutMs) => timeoutMs, timeOut }); + try { + await expect( + ask(createAndroidObservationAdapter(host), { ...device, id: `emulator-${question}` }), + ).resolves.toEqual(unanswered); + } finally { + restore(); + } expect(host.runAdb).toHaveBeenCalledTimes(1); }, ); diff --git a/packages/platform-android/src/observation.ts b/packages/platform-android/src/observation.ts index 442fd9311d..6538312dd8 100644 --- a/packages/platform-android/src/observation.ts +++ b/packages/platform-android/src/observation.ts @@ -33,7 +33,7 @@ const PERMISSION_PACKAGES = new Set([ 'com.google.android.packageinstaller', ]); -const DUMP_READ_TIMEOUT_MS = 10_000; +const DUMP_OBSERVATION_BUDGET_MS = 10_000; type Question = 'foreground' | 'blockingDialog'; type DumpReader = (words: readonly string[]) => Promise; @@ -83,34 +83,35 @@ export function createAndroidObservationAdapter( } /** - * A dump that times out is an unanswered read, like one that printed nothing: the observation - * reports `unknown` and never refuses the command. After one timeout the device is not answering, - * so the reader skips its remaining variants and an observation costs at most one bound. + * Every variant one observation reads shares a single {@link DUMP_OBSERVATION_BUDGET_MS} budget, + * so an observation costs at most one bound however many variants it walks. A read that times out + * or finds the budget spent is an unanswered read, like one that printed nothing: the observation + * reports `unknown` and never refuses the command. */ function createDumpReader(host: AndroidObservationHost, device: DeviceInfo): DumpReader { const dumps = new Map>(); - let timedOut = false; + const deadlineMs = Date.now() + DUMP_OBSERVATION_BUDGET_MS; return (words) => { - if (timedOut) return Promise.resolve(''); const key = words.join(' '); - const pending = - dumps.get(key) ?? - host - .runAdb(device, deviceShellArgv('adb', 'shell', words), { - allowFailure: true, - timeoutMs: DUMP_READ_TIMEOUT_MS, - }) - .then( - (result) => { - recordSections(device, key, result.stdout); - return result.stdout; - }, - (error: unknown) => { - if (!isCommandTimeoutError(error)) throw error; - timedOut = true; - return ''; - }, - ); + const cached = dumps.get(key); + if (cached) return cached; + const remainingMs = deadlineMs - Date.now(); + if (remainingMs <= 0) return Promise.resolve(''); + const pending = host + .runAdb(device, deviceShellArgv('adb', 'shell', words), { + allowFailure: true, + timeoutMs: remainingMs, + }) + .then( + (result) => { + recordSections(device, key, result.stdout); + return result.stdout; + }, + (error: unknown) => { + if (!isCommandTimeoutError(error)) throw error; + return ''; + }, + ); dumps.set(key, pending); return pending; }; From 31676642a14558a57c71ee5a393c90798cf79609 Mon Sep 17 00:00:00 2001 From: Adam Rowe Date: Sat, 24 Oct 2026 00:31:04 -0400 Subject: [PATCH 3/5] fix(android): start an observation's dump budget at its first dump read The budget started when the reader was created, so the helper focus call that readBlockingDialog (and readAppFocus, for a shared reader) makes first spent up to 2 s of the 10 s dump budget. The deadline now starts at the first dump read. Co-Authored-By: Claude Opus 5.5 --- .../platform-android/src/observation.test.ts | 45 ++++++++++++++++++- packages/platform-android/src/observation.ts | 9 ++-- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/packages/platform-android/src/observation.test.ts b/packages/platform-android/src/observation.test.ts index c20e807ff6..c9255531fd 100644 --- a/packages/platform-android/src/observation.test.ts +++ b/packages/platform-android/src/observation.test.ts @@ -2,8 +2,14 @@ import { expect, test, vi } from 'vitest'; import type { AndroidObservationHost } from '@agent-device/contracts/android-observation'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; +import { runAndroidAutomationHelper } from './automation-helper.ts'; import { createAndroidObservationAdapter } from './observation.ts'; +vi.mock('./automation-helper.ts', async (importOriginal) => ({ + ...(await importOriginal()), + runAndroidAutomationHelper: vi.fn(async () => undefined), +})); + const device = { platform: 'android', target: 'mobile', @@ -126,6 +132,7 @@ const QUESTIONS = [ function slowHost(params: { spendMs: (timeoutMs: number) => number; timeOut?: boolean }): { host: AndroidObservationHost; timeouts: number[]; + advance: (ms: number) => void; restore: () => void; } { const originMs = Date.now(); @@ -142,7 +149,14 @@ function slowHost(params: { spendMs: (timeoutMs: number) => number; timeOut?: bo return { exitCode: 0, stdout: '', stderr: '' }; }), }; - return { host, timeouts, restore: () => now.mockRestore() }; + return { + host, + timeouts, + advance: (ms) => { + elapsedMs += ms; + }, + restore: () => now.mockRestore(), + }; } test('bounds every dump read the foreground question makes', async () => { @@ -214,3 +228,32 @@ test('a dump that fails without timing out still fails the observation', async ( createAndroidObservationAdapter(host).readAppState({ ...device, id: 'emulator-offline' }), ).rejects.toBe(failure); }); + +test('the helper focus call before the first dump does not spend the dump budget', async () => { + const unhurried = slowHost({ spendMs: () => 0 }); + try { + await createAndroidObservationAdapter(unhurried.host).readBlockingDialog({ + ...device, + id: 'emulator-focus-fast', + }); + } finally { + unhurried.restore(); + } + + const slowFocus = slowHost({ spendMs: () => 0 }); + const focusCostMs = Math.floor(unhurried.timeouts[0]! / 2); + vi.mocked(runAndroidAutomationHelper).mockImplementationOnce(async () => { + slowFocus.advance(focusCostMs); + return undefined; + }); + try { + await createAndroidObservationAdapter(slowFocus.host).readBlockingDialog({ + ...device, + id: 'emulator-focus-slow', + }); + } finally { + slowFocus.restore(); + } + + expect(slowFocus.timeouts[0]).toBe(unhurried.timeouts[0]); +}); diff --git a/packages/platform-android/src/observation.ts b/packages/platform-android/src/observation.ts index 6538312dd8..a3bf7c0cbd 100644 --- a/packages/platform-android/src/observation.ts +++ b/packages/platform-android/src/observation.ts @@ -84,17 +84,18 @@ export function createAndroidObservationAdapter( /** * Every variant one observation reads shares a single {@link DUMP_OBSERVATION_BUDGET_MS} budget, - * so an observation costs at most one bound however many variants it walks. A read that times out - * or finds the budget spent is an unanswered read, like one that printed nothing: the observation - * reports `unknown` and never refuses the command. + * counted from its first dump read, so an observation costs at most one bound however many + * variants it walks. A read that times out or finds the budget spent is an unanswered read, like + * one that printed nothing: the observation reports `unknown` and never refuses the command. */ function createDumpReader(host: AndroidObservationHost, device: DeviceInfo): DumpReader { const dumps = new Map>(); - const deadlineMs = Date.now() + DUMP_OBSERVATION_BUDGET_MS; + let deadlineMs: number | undefined; return (words) => { const key = words.join(' '); const cached = dumps.get(key); if (cached) return cached; + deadlineMs ??= Date.now() + DUMP_OBSERVATION_BUDGET_MS; const remainingMs = deadlineMs - Date.now(); if (remainingMs <= 0) return Promise.resolve(''); const pending = host From f02edea8ecbba480c8204c5001c48dd2afceeed6 Mon Sep 17 00:00:00 2001 From: Adam Rowe Date: Sat, 24 Oct 2026 00:35:24 -0400 Subject: [PATCH 4/5] refactor(android): track the dump budget with host-kit's Deadline Matches the other platform-android modules that bound a sequence of steps, instead of computing the deadline and remainder by hand. Co-Authored-By: Claude Opus 5.5 --- packages/platform-android/src/observation.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/platform-android/src/observation.ts b/packages/platform-android/src/observation.ts index a3bf7c0cbd..5c190f61a0 100644 --- a/packages/platform-android/src/observation.ts +++ b/packages/platform-android/src/observation.ts @@ -6,6 +6,7 @@ import type { import type { AppStateRuntimeResult } from '@agent-device/contracts/app-state-runtime'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { isCommandTimeoutError } from '@agent-device/host-kit/command'; +import { Deadline } from '@agent-device/host-kit/retry'; import { deviceShellArgv } from '@agent-device/kernel/device-shell'; import { AppError } from '@agent-device/kernel/errors'; import { @@ -90,18 +91,17 @@ export function createAndroidObservationAdapter( */ function createDumpReader(host: AndroidObservationHost, device: DeviceInfo): DumpReader { const dumps = new Map>(); - let deadlineMs: number | undefined; + let deadline: Deadline | undefined; return (words) => { const key = words.join(' '); const cached = dumps.get(key); if (cached) return cached; - deadlineMs ??= Date.now() + DUMP_OBSERVATION_BUDGET_MS; - const remainingMs = deadlineMs - Date.now(); - if (remainingMs <= 0) return Promise.resolve(''); + deadline ??= Deadline.fromTimeoutMs(DUMP_OBSERVATION_BUDGET_MS); + if (deadline.isExpired()) return Promise.resolve(''); const pending = host .runAdb(device, deviceShellArgv('adb', 'shell', words), { allowFailure: true, - timeoutMs: remainingMs, + timeoutMs: deadline.remainingMs(), }) .then( (result) => { From cc4dd4a2f0912656851ef00381e7a26e0d956e24 Mon Sep 17 00:00:00 2001 From: Adam Rowe Date: Sat, 24 Oct 2026 01:35:48 -0400 Subject: [PATCH 5/5] fix(android): read the dump deadline once per read isExpired() and remainingMs() read the clock separately, so a millisecond rollover between them passed timeoutMs 0, which the exec layer treats as no timeout and runs the dump unbounded. The reader now reads the remaining budget once and uses it for both the check and the timeout. Co-Authored-By: Claude Opus 5.5 --- .../platform-android/src/observation.test.ts | 31 +++++++++++++++++++ packages/platform-android/src/observation.ts | 5 +-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/platform-android/src/observation.test.ts b/packages/platform-android/src/observation.test.ts index c9255531fd..5fc3ebf497 100644 --- a/packages/platform-android/src/observation.test.ts +++ b/packages/platform-android/src/observation.test.ts @@ -257,3 +257,34 @@ test('the helper focus call before the first dump does not spend the dump budget expect(slowFocus.timeouts[0]).toBe(unhurried.timeouts[0]); }); + +test('a read that starts with the budget nearly spent never passes a non-positive timeout', async () => { + // Each clock read advances 1 ms, so two reads of one deadline disagree by one millisecond. + const originMs = Date.now(); + let elapsedMs = 0; + const now = vi.spyOn(Date, 'now').mockImplementation(() => { + elapsedMs += 1; + return originMs + elapsedMs; + }); + const timeouts: number[] = []; + const host: AndroidObservationHost = { + ...hostFor(new Map()), + runAdb: vi.fn(async (_device, _args, options) => { + const timeoutMs = options?.timeoutMs ?? Number.POSITIVE_INFINITY; + timeouts.push(timeoutMs); + elapsedMs += timeoutMs - 2; + return { exitCode: 0, stdout: '', stderr: '' }; + }), + }; + try { + await createAndroidObservationAdapter(host).readAppState({ + ...device, + id: 'emulator-rollover', + }); + } finally { + now.mockRestore(); + } + + expect(timeouts.length).toBeGreaterThan(1); + for (const timeoutMs of timeouts) expect(timeoutMs).toBeGreaterThan(0); +}); diff --git a/packages/platform-android/src/observation.ts b/packages/platform-android/src/observation.ts index 5c190f61a0..47beb8a2ac 100644 --- a/packages/platform-android/src/observation.ts +++ b/packages/platform-android/src/observation.ts @@ -97,11 +97,12 @@ function createDumpReader(host: AndroidObservationHost, device: DeviceInfo): Dum const cached = dumps.get(key); if (cached) return cached; deadline ??= Deadline.fromTimeoutMs(DUMP_OBSERVATION_BUDGET_MS); - if (deadline.isExpired()) return Promise.resolve(''); + const remainingMs = deadline.remainingMs(); + if (remainingMs <= 0) return Promise.resolve(''); const pending = host .runAdb(device, deviceShellArgv('adb', 'shell', words), { allowFailure: true, - timeoutMs: deadline.remainingMs(), + timeoutMs: remainingMs, }) .then( (result) => {