fix(adb): report observed output after failed file pull (#9596)

This commit is contained in:
morluto authored and GitHub committed 2026-11-09 19:34:16 +08:00
1 parent a6c10a28f4
commit a874cef9af
4 files changed
+113 -2

No files matched your search

+5 -1
View File
@@ -188,7 +188,7 @@ and during shutdown.
property value remains available in `properties`.
- **Device paths are not host paths.** `base_apk_device_path` values live on
the device filesystem. Only pulls and captures produce host-local paths,
each paired with byte size and SHA-256 digest. A pulled file's host name is
with byte size and SHA-256 digest on success. A pulled file's host name is
the device path's final POSIX segment. REA preserves the original device
path and filename; a missing final segment or `.`/`..` is refused before
output creation. Windows hosts follow [Windows filename rules](https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file),
@@ -249,6 +249,10 @@ and during shutdown.
- Pull and capture outputs are additive: the destination must not already
exist. Re-running a completed pull refuses the existing directory instead
of overwriting evidence.
- If a single-file pull fails and REA observes a regular file at the destination,
the error includes its `local_path` with `transfer_status: "unconfirmed"`
and `digest_status: "unknown"`. The file remains in place; the receipt does
not establish that the transfer completed.
- Cancellation during a package pull preserves caller-selected output. The
error includes partial observations identifying completed artifacts and the
interrupted local path; remaining APKs were not attempted.
+20 -1
View File
@@ -2333,7 +2333,26 @@ export class AdbProvider {
{ timeoutMs: PULL_TIMEOUT_MS, maxOutputBytes: PULL_MAX_OUTPUT_BYTES },
signal,
);
if (!pulled.ok) return pulled;
if (!pulled.ok) {
// A failed adb process can leave caller-visible bytes at the selected
// destination. Report only an observed regular file; its transfer and
// digest are unconfirmed, and the caller owns the output.
const output = await lstat(localPath).catch(() => undefined);
if (output?.isFile() === true && !output.isSymbolicLink())
pulled.error.retainPartialObservation({
provider_id: "adb",
operation,
result: {
client,
serial: input.serial,
local_path: localPath,
device_path: input.device_path,
digest_status: "unknown",
transfer_status: "unconfirmed",
},
});
return pulled;
}
try {
const digest = await hashAdbFile(
localPath,
+1
View File
@@ -892,6 +892,7 @@ export type AdbAcquiredFilePartialObservation =
readonly local_path: string;
readonly device_path: string;
readonly digest_status: "unknown";
readonly transfer_status?: "unconfirmed";
}
| z.infer<typeof adbResultSchemas.pull_adb_file>;
}
@@ -11,6 +11,7 @@ import { dirname, join } from "node:path";
import { expect, onTestFinished, test, vi } from "vitest";
import { AdbProvider } from "../../../../src/adb/AdbProvider.js";
import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js";
import { AdbDeviceAnalysisService } from "../../../../src/application/adb/AdbDeviceAnalysisService.js";
import { PrivateRuntimeRoot } from "../../../../src/process/PrivateRuntimeRoot.js";
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
@@ -246,3 +247,89 @@ it.each([
}
},
);
it.each([true, false])(
"reports only observed output when adb pull fails (file created: %s)",
async (created) => {
const root = await createTestTempDirectory("rea-adb-partial-pull-");
const binary = join(root, "adb");
await writeFile(
binary,
`#!/bin/sh
if [ "$1" = version ]; then printf 'Android Debug Bridge version 1.0.41\\nVersion 34.0.5-stub\\nInstalled as %s\\n' "$0"; exit 0; fi
if [ "$3" = pull ]; then ${created ? `printf 'partial bytes' > "$5";` : ""} printf 'transfer interrupted\\n' >&2; exit 1; fi
exit 1
`,
{ mode: 0o755 },
);
const provider = new AdbProvider({ environment: { REA_ADB_PATH: binary } });
onTestFinished(() => provider.close());
const result = await provider.execute({
operation: "pull_adb_file",
input: {
serial: "fixture",
device_path: "/sdcard/payload.bin",
output_directory: root,
},
});
if (result.ok) throw new Error("expected failed pull");
if (created) {
expect(await readFile(join(root, "payload.bin"), "utf8")).toBe(
"partial bytes",
);
expect(
projectAnalysisError(result.error).details?.partial_observation,
).toMatchObject({
operation: "pull_adb_file",
result: {
local_path: join(root, "payload.bin"),
digest_status: "unknown",
transfer_status: "unconfirmed",
},
});
} else {
await expect(access(join(root, "payload.bin"))).rejects.toMatchObject({
code: "ENOENT",
});
expect(result.error.partialObservation).toBeUndefined();
}
},
);
it.each(["file", "symlink"] as const)(
"refuses a preexisting %s destination without invoking adb pull",
async (kind) => {
const root = await createTestTempDirectory("rea-adb-existing-pull-");
const binary = join(root, "adb");
const destination = join(root, "payload.bin");
const source = join(root, "original");
await writeFile(source, "original bytes");
if (kind === "file") await writeFile(destination, "original bytes");
else await symlink(source, destination);
await writeFile(
binary,
`#!/bin/sh
if [ "$1" = version ]; then printf 'Android Debug Bridge version 1.0.41\\nVersion 34.0.5-stub\\nInstalled as %s\\n' "$0"; exit 0; fi
if [ "$3" = pull ]; then printf 'invoked' > "$0.called"; exit 1; fi
exit 1
`,
{ mode: 0o755 },
);
const provider = new AdbProvider({ environment: { REA_ADB_PATH: binary } });
onTestFinished(() => provider.close());
const result = await provider.execute({
operation: "pull_adb_file",
input: {
serial: "fixture",
device_path: "/sdcard/payload.bin",
output_directory: root,
},
});
if (result.ok) throw new Error("expected destination refusal");
expect(result.error.partialObservation).toBeUndefined();
expect(await readFile(destination, "utf8")).toBe("original bytes");
await expect(access(`${binary}.called`)).rejects.toMatchObject({
code: "ENOENT",
});
},
);