diff --git a/packages/adapter-utils/src/command-managed-runtime.test.ts b/packages/adapter-utils/src/command-managed-runtime.test.ts index 7b79daccf05..a910fb804a9 100644 --- a/packages/adapter-utils/src/command-managed-runtime.test.ts +++ b/packages/adapter-utils/src/command-managed-runtime.test.ts @@ -22,9 +22,10 @@ interface SpawnRunnerHandle { // A runner that actually executes the shell scripts (piping stdin through a real // pipe so multi-MB payloads work) and replays stdout through onLog in several // chunks so the streaming readFile byte-counter is exercised. -function makeSpawnRunner(): SpawnRunnerHandle { +function makeSpawnRunner(options: { supportsSingleStreamStdinProgress?: boolean } = {}): SpawnRunnerHandle { const calls: Array<{ command: string; args?: string[]; cwd?: string; stdin?: string }> = []; const runner: CommandManagedRuntimeRunner = { + supportsSingleStreamStdinProgress: options.supportsSingleStreamStdinProgress, execute: async (input) => await new Promise((resolve) => { calls.push({ command: input.command, args: input.args, cwd: input.cwd, stdin: input.stdin }); @@ -238,7 +239,7 @@ describe("command managed runtime", () => { const payload = Buffer.alloc(3 * 1024 * 1024); for (let i = 0; i < payload.length; i++) payload[i] = i % 256; - const { runner, calls } = makeSpawnRunner(); + const { runner, calls } = makeSpawnRunner({ supportsSingleStreamStdinProgress: true }); const client = createCommandManagedRuntimeClient({ runner, commandCwd: "/", timeoutMs: 30_000 }); const progress: Array<{ done: number; total: number | null }> = []; @@ -263,6 +264,37 @@ describe("command managed runtime", () => { expect(progress.at(-1)).toEqual({ done: payload.length, total: payload.length }); }); + it("falls back to chunked upload progress when the runner cannot report mid-stream stdin progress", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-command-write-fallback-")); + cleanupDirs.push(rootDir); + const remotePath = path.join(rootDir, "nested", "payload.bin"); + + const payload = Buffer.alloc(12 * 1024 * 1024); + for (let i = 0; i < payload.length; i++) payload[i] = i % 256; + + const { runner, calls } = makeSpawnRunner({ supportsSingleStreamStdinProgress: false }); + const client = createCommandManagedRuntimeClient({ runner, commandCwd: "/", timeoutMs: 30_000 }); + + const progress: Array<{ done: number; total: number | null }> = []; + await client.writeFile(remotePath, toArrayBuffer(payload), { + onProgress: (done, total) => { + progress.push({ done, total }); + }, + }); + + const written = await readFile(remotePath); + expect(written.equals(payload)).toBe(true); + + // Provider-backed sandbox runners cannot surface mid-flight progress for a + // single stdin RPC, so we intentionally use several large append commands. + expect(calls.length).toBeGreaterThan(2); + expect(progress.length).toBeGreaterThan(2); + for (let i = 1; i < progress.length; i++) { + expect(progress[i].done).toBeGreaterThanOrEqual(progress[i - 1].done); + } + expect(progress.at(-1)).toEqual({ done: payload.length, total: payload.length }); + }); + it("streams a download through onLog and reports monotonic byte progress to the total", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-command-read-")); cleanupDirs.push(rootDir); diff --git a/packages/adapter-utils/src/command-managed-runtime.ts b/packages/adapter-utils/src/command-managed-runtime.ts index 30ace78de78..a74789cc2f6 100644 --- a/packages/adapter-utils/src/command-managed-runtime.ts +++ b/packages/adapter-utils/src/command-managed-runtime.ts @@ -11,6 +11,13 @@ import type { RunProcessResult } from "./server-utils.js"; import type { RuntimeProgressSink } from "./runtime-progress.js"; export interface CommandManagedRuntimeRunner { + /** + * True only when `execute({ stdin })` can surface useful in-flight progress + * for a single stdin-backed command. Provider-backed sandbox runners usually + * complete the entire RPC before returning, so they should leave this false + * and let the caller choose a chunked upload path when progress is requested. + */ + supportsSingleStreamStdinProgress?: boolean; execute(input: { command: string; args?: string[]; @@ -100,13 +107,18 @@ export function createCommandManagedRuntimeClient(input: { const body = buffer.toString("base64"); const remoteDir = path.posix.dirname(remotePath); const remoteTempPath = `${remotePath}.paperclip-upload`; + const canUseSingleStreamProgressPath = + !options?.onProgress || input.runner.supportsSingleStreamStdinProgress === true; // Primary path: a single round-trip. Stream the entire base64 body to one // `base64 -d` process via stdin, decode straight into a temp file, then // atomically rename into place. This replaces the previous loop that did // one `printf >> tmpfile` shell round-trip per 32 KB — thousands of serial // processes for a large workspace — with exactly one process. - if (body.length <= REMOTE_WRITE_SINGLE_STREAM_MAX_BASE64_BYTES) { + if ( + body.length <= REMOTE_WRITE_SINGLE_STREAM_MAX_BASE64_BYTES && + canUseSingleStreamProgressPath + ) { await options?.onProgress?.(0, total); await runShell( `mkdir -p ${shellQuote(remoteDir)} && ` + diff --git a/server/src/services/environment-execution-target.ts b/server/src/services/environment-execution-target.ts index 33ea70155c8..0e50bd7b6a8 100644 --- a/server/src/services/environment-execution-target.ts +++ b/server/src/services/environment-execution-target.ts @@ -75,6 +75,7 @@ export async function resolveEnvironmentExecutionTarget(input: { timeoutMs, runner: input.environmentRuntime && input.lease ? { + supportsSingleStreamStdinProgress: false, execute: async (commandInput) => { const startedAt = new Date().toISOString(); const result = await input.environmentRuntime!.execute({