From 862468fd4e1bc07974ba3822c1fa1de0a3ec6ca3 Mon Sep 17 00:00:00 2001 From: Pocket Shark Date: Fri, 18 Sep 2026 20:27:41 +0800 Subject: [PATCH 1/6] fix(drive): map bare HTTP 413 responses to FILE_TOO_LARGE --- src/handwritten/commands/drive/retry.ts | 3 ++- test/handwritten/drive/retry.test.ts | 10 +++++++++- test/handwritten/drive/sync.test.ts | 15 ++++++++++++++- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/handwritten/commands/drive/retry.ts b/src/handwritten/commands/drive/retry.ts index dfde51f..8319f1e 100644 --- a/src/handwritten/commands/drive/retry.ts +++ b/src/handwritten/commands/drive/retry.ts @@ -41,7 +41,8 @@ export class DriveRetryableSyncError extends Error { } export function driveHttpError(response: Response, payload?: unknown, now: DateTime = DateTime.utc()): DriveHttpError { - const code = errorCode(payload) + // The edge rejects oversized bodies with a bare 413 before the worker adds a code. + const code = errorCode(payload) ?? (response.status === 413 ? "FILE_TOO_LARGE" : undefined) const retryAfterMs = parseRetryAfter(response.headers.get("retry-after") ?? undefined, now) return new DriveHttpError(response.status, { ...(code === undefined ? {} : { code }), diff --git a/test/handwritten/drive/retry.test.ts b/test/handwritten/drive/retry.test.ts index b864efc..65bdc88 100644 --- a/test/handwritten/drive/retry.test.ts +++ b/test/handwritten/drive/retry.test.ts @@ -1,8 +1,16 @@ import { describe, expect, it } from "vitest" import { DateTime } from "luxon" -import { DriveHttpError, classifyDriveRetry, isPermanentUploadRejection, parseRetryAfter } from "../../../src/handwritten/commands/drive/retry.js" +import { DriveHttpError, classifyDriveRetry, driveHttpError, isPermanentUploadRejection, parseRetryAfter } from "../../../src/handwritten/commands/drive/retry.js" describe("Drive retry policy", () => { + it("maps a 413 without a code to FILE_TOO_LARGE", () => { + expect(driveHttpError(new Response("", { status: 413 })).code).toBe("FILE_TOO_LARGE") + }) + + it("keeps the server code on a 413", () => { + expect(driveHttpError(new Response("", { status: 413 }), { code: "SOMETHING_ELSE" }).code).toBe("SOMETHING_ELSE") + }) + it("parses Retry-After delta seconds", () => { const now = DateTime.fromISO("2026-07-23T00:00:00Z", { setZone: true }) diff --git a/test/handwritten/drive/sync.test.ts b/test/handwritten/drive/sync.test.ts index 663ec79..8190392 100644 --- a/test/handwritten/drive/sync.test.ts +++ b/test/handwritten/drive/sync.test.ts @@ -10,7 +10,7 @@ import { driveSyncCommand, runDriveSyncOnce, type DriveSyncApi } from "../../../ import { render } from "../../../src/handwritten/output/render.js" import type { UploadDriveFileResponse } from "../../../src/generated/sdk/index.js" import type { DriveClock } from "../../../src/handwritten/commands/drive/clock.js" -import { DriveHttpError } from "../../../src/handwritten/commands/drive/retry.js" +import { DriveHttpError, driveHttpError } from "../../../src/handwritten/commands/drive/retry.js" import { VERSION } from "../../../src/version.js" const stateWriteControl = vi.hoisted(() => ({ @@ -1910,6 +1910,19 @@ describe("drive sync once", () => { expect((await readDriveState(root)).upload_rejections).toBeUndefined() }) + it("reports a bare upload 413 as FILE_TOO_LARGE and remembers that code", async () => { + const root = await mkdtemp(join(tmpdir(), "wspc-drive-sync-rejected-413-")) + await initDriveState(root, "lib_1") + await writeFile(join(root, "big.jsonl"), "huge") + const api = mkApi([{ entries: [] }]) + rejectUploadsOf(api, "big.jsonl", () => driveHttpError(new Response("", { status: 413 }))) + + const result = await runDriveSyncOnce(root, api) + + expect(result.path_errors).toEqual([{ ...rejected413, code: "FILE_TOO_LARGE" }]) + expect((await readDriveState(root)).upload_rejections?.["big.jsonl"]?.code).toBe("FILE_TOO_LARGE") + }) + it.each([ ["502", () => new DriveHttpError(502)], ["429", () => new DriveHttpError(429)], From 34bded5dbbfcf5f4b5c6b07bbdf1b07bc0b889f5 Mon Sep 17 00:00:00 2001 From: Pocket Shark Date: Fri, 18 Sep 2026 20:28:24 +0800 Subject: [PATCH 2/6] feat(drive): skip uploading files over the Drive per-file limit --- src/handwritten/commands/drive/sync.ts | 25 ++++++++++++++ test/handwritten/drive/sync.test.ts | 48 +++++++++++++++++++++++++- 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/src/handwritten/commands/drive/sync.ts b/src/handwritten/commands/drive/sync.ts index 3697851..f54a6ee 100644 --- a/src/handwritten/commands/drive/sync.ts +++ b/src/handwritten/commands/drive/sync.ts @@ -57,6 +57,10 @@ function emptySummary(): DriveSyncSummary { } } +// Mirrors MAX_FILE_SIZE_BYTES in sadcoderlabs/wspc packages/drive/worker/src/limits.ts; +// the server does not expose it, so a server change needs a CLI release. +export const DRIVE_MAX_FILE_SIZE_BYTES = 100 * 1024 * 1024 + export type DriveSyncProgress = (processed: number, total: number) => void // Progress counts actionable paths only (transfers and conflict handling); @@ -179,6 +183,26 @@ export async function runDriveSyncOnce( } const rejectedPaths = new Set() + for (const path of paths) { + const sizeBytes = localFiles[path]?.size_bytes + if (movedPaths.has(path) || sizeBytes === undefined || sizeBytes <= DRIVE_MAX_FILE_SIZE_BYTES) continue + const action = decideDriveAction(state.entries[path], localFiles[path], remoteFiles[path]) + if (action.type !== "upload_create" && action.type !== "upload_update") continue + rejectedPaths.add(path) + const pathError = { + path, + code: "FILE_TOO_LARGE", + message: `file is ${(sizeBytes / 1_048_576).toFixed(1)} MiB (${sizeBytes} bytes); Drive per-file limit is 100 MiB`, + retryable: false, + } + await recordDrivePathError(summary, undefined, path, undefined, { + appendPathResult: true, + debug, + op: "file_too_large", + pathError, + }) + } + const uploadRejections = { ...state.upload_rejections } for (const [path, rejection] of Object.entries(uploadRejections)) { const scanned = state.scan_cache?.[path] @@ -191,6 +215,7 @@ export async function runDriveSyncOnce( delete uploadRejections[path] continue } + if (rejectedPaths.has(path)) continue const action = decideDriveAction(state.entries[path], localFiles[path], remoteFiles[path]) if (action.type !== "upload_create" && action.type !== "upload_update") continue rejectedPaths.add(path) diff --git a/test/handwritten/drive/sync.test.ts b/test/handwritten/drive/sync.test.ts index 8190392..12b1b11 100644 --- a/test/handwritten/drive/sync.test.ts +++ b/test/handwritten/drive/sync.test.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto" import { spawnSync } from "node:child_process" -import { mkdir, mkdtemp, readFile, readdir, rename, unlink, utimes, writeFile } from "node:fs/promises" +import { mkdir, mkdtemp, readFile, readdir, rename, truncate, unlink, utimes, writeFile } from "node:fs/promises" import { join } from "node:path" import { tmpdir } from "node:os" import { DateTime } from "luxon" @@ -1968,6 +1968,52 @@ describe("drive sync once", () => { }) }) + describe("oversized file", () => { + const overLimit = 104_857_601 + const tooLarge = (path: string) => ({ + path, + code: "FILE_TOO_LARGE", + message: "file is 100.0 MiB (104857601 bytes); Drive per-file limit is 100 MiB", + retryable: false, + }) + + async function sparse(root: string, path: string, size: number): Promise { + await writeFile(join(root, path), "") + await truncate(join(root, path), size) + } + + it("never reads or uploads an oversized file and reports it every round", async () => { + const root = await mkdtemp(join(tmpdir(), "wspc-drive-sync-oversized-")) + await initDriveState(root, "lib_1") + await sparse(root, "big.bin", overLimit) + const api = mkApi([{ entries: [] }, { entries: [] }]) + + for (let round = 0; round < 2; round++) { + const summary = await runDriveSyncOnce(root, api) + expect(summary.path_errors).toEqual([tooLarge("big.bin")]) + expect(summary.errors).toBe(1) + expect(summary.paths).toEqual([{ path: "big.bin", action: "error" }]) + } + expect(uploadCount(api, "big.bin")).toBe(0) + }) + + it("leaves an oversized file out of progress and syncs other files", async () => { + const root = await mkdtemp(join(tmpdir(), "wspc-drive-sync-oversized-others-")) + await initDriveState(root, "lib_1") + await sparse(root, "big.bin", overLimit) + await writeFile(join(root, "notes.txt"), "hello") + const api = mkApi([{ entries: [] }]) + const totals: number[] = [] + + const summary = await runDriveSyncOnce(root, api, undefined, (_processed, total) => totals.push(total)) + + expect(new Set(totals)).toEqual(new Set([1])) + expect(uploadCount(api, "notes.txt")).toBe(1) + expect(uploadCount(api, "big.bin")).toBe(0) + expect(summary.uploaded).toBe(1) + }) + }) + it("renders command summary and sets exit code for conflicts", async () => { const root = await mkdtemp(join(tmpdir(), "wspc-drive-sync-command-")) const state = await initDriveState(root, "lib_1") From d194897ecd19a7c4a9679611e7e9ec4090eee130 Mon Sep 17 00:00:00 2001 From: Pocket Shark Date: Fri, 18 Sep 2026 20:28:56 +0800 Subject: [PATCH 3/6] test(drive): guard the per-file limit boundary and grown synced files --- test/handwritten/drive/sync.test.ts | 35 +++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/test/handwritten/drive/sync.test.ts b/test/handwritten/drive/sync.test.ts index 12b1b11..2406ec1 100644 --- a/test/handwritten/drive/sync.test.ts +++ b/test/handwritten/drive/sync.test.ts @@ -2012,6 +2012,41 @@ describe("drive sync once", () => { expect(uploadCount(api, "big.bin")).toBe(0) expect(summary.uploaded).toBe(1) }) + + it("uploads a file of exactly 100 MiB", async () => { + const root = await mkdtemp(join(tmpdir(), "wspc-drive-sync-oversized-boundary-")) + await initDriveState(root, "lib_1") + await sparse(root, "edge.bin", 104_857_600) + const api = mkApi([{ entries: [] }]) + const uploadedBytes: number[] = [] + api.uploadFile = async (id, path, body, digest, expectedEntryVersion) => { + api.uploads.push({ id, path, sha256: digest, expectedEntryVersion }) + uploadedBytes.push(typeof body === "string" ? Buffer.byteLength(body) : body.byteLength) + return { + entry: { ...entry(path, "", 1), content_sha256: digest, size_bytes: 104_857_600 }, + result: "created", + } + } + + const summary = await runDriveSyncOnce(root, api) + + expect(uploadedBytes).toEqual([104_857_600]) + expect(summary.path_errors ?? []).toEqual([]) + }) + + it("stops uploading a synced file once it grows over the limit", async () => { + const root = await mkdtemp(join(tmpdir(), "wspc-drive-sync-oversized-grown-")) + const state = await initDriveState(root, "lib_1") + state.entries["big.bin"] = stateEntry("big.bin", "small") + await writeDriveState(root, state) + await sparse(root, "big.bin", overLimit) + const api = mkApi([{ entries: [entry("big.bin", "small")] }]) + + const summary = await runDriveSyncOnce(root, api) + + expect(uploadCount(api, "big.bin")).toBe(0) + expect(summary.path_errors).toEqual([tooLarge("big.bin")]) + }) }) it("renders command summary and sets exit code for conflicts", async () => { From ec00d1dbebe7101af2594a2c971f1625af4d8bf1 Mon Sep 17 00:00:00 2001 From: Pocket Shark Date: Fri, 18 Sep 2026 20:29:29 +0800 Subject: [PATCH 4/6] test(drive): cover oversized file recovery and legacy rejection records --- src/handwritten/commands/drive/sync.ts | 1 - test/handwritten/drive/sync.test.ts | 104 +++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 1 deletion(-) diff --git a/src/handwritten/commands/drive/sync.ts b/src/handwritten/commands/drive/sync.ts index f54a6ee..5cdbde6 100644 --- a/src/handwritten/commands/drive/sync.ts +++ b/src/handwritten/commands/drive/sync.ts @@ -215,7 +215,6 @@ export async function runDriveSyncOnce( delete uploadRejections[path] continue } - if (rejectedPaths.has(path)) continue const action = decideDriveAction(state.entries[path], localFiles[path], remoteFiles[path]) if (action.type !== "upload_create" && action.type !== "upload_update") continue rejectedPaths.add(path) diff --git a/test/handwritten/drive/sync.test.ts b/test/handwritten/drive/sync.test.ts index 2406ec1..181cfae 100644 --- a/test/handwritten/drive/sync.test.ts +++ b/test/handwritten/drive/sync.test.ts @@ -2047,6 +2047,110 @@ describe("drive sync once", () => { expect(uploadCount(api, "big.bin")).toBe(0) expect(summary.path_errors).toEqual([tooLarge("big.bin")]) }) + + async function oversizedLibrary(prefix: string, rounds: number): Promise<{ root: string; api: TestDriveSyncApi }> { + const root = await mkdtemp(join(tmpdir(), prefix)) + await initDriveState(root, "lib_1") + await sparse(root, "big.bin", overLimit) + const api = mkApi(Array.from({ length: rounds }, () => ({ entries: [] }))) + await runDriveSyncOnce(root, api) + return { root, api } + } + + it("uploads the file once it shrinks within the limit", async () => { + const { root, api } = await oversizedLibrary("wspc-drive-sync-oversized-shrink-", 2) + await writeFile(join(root, "big.bin"), "small") + + const summary = await runDriveSyncOnce(root, api) + + expect(summary.path_errors ?? []).toEqual([]) + expect(uploadCount(api, "big.bin")).toBe(1) + }) + + it.each([ + ["deleted", (root: string) => unlink(join(root, "big.bin"))], + ["excluded", (root: string) => writeFile(join(root, ".wspc-drive", "ignore"), "big.bin\n")], + ])("stops reporting the file once it is %s", async (_name, change) => { + const { root, api } = await oversizedLibrary("wspc-drive-sync-oversized-gone-", 2) + await change(root) + + const summary = await runDriveSyncOnce(root, api) + + expect(summary.path_errors ?? []).toEqual([]) + expect(uploadCount(api, "big.bin")).toBe(0) + }) + + it("reports the new path after renaming an oversized file", async () => { + const { root, api } = await oversizedLibrary("wspc-drive-sync-oversized-rename-", 2) + await rename(join(root, "big.bin"), join(root, "renamed.bin")) + + const summary = await runDriveSyncOnce(root, api) + + expect(summary.path_errors).toEqual([tooLarge("renamed.bin")]) + expect(api.uploads).toEqual([]) + }) + + it("uploads a renamed file once it fits within the limit", async () => { + const { root, api } = await oversizedLibrary("wspc-drive-sync-oversized-rename-small-", 2) + await unlink(join(root, "big.bin")) + await writeFile(join(root, "renamed.bin"), "small") + + const summary = await runDriveSyncOnce(root, api) + + expect(summary.path_errors ?? []).toEqual([]) + expect(uploadCount(api, "renamed.bin")).toBe(1) + }) + + it("replaces a rejection recorded by an older CLI with FILE_TOO_LARGE", async () => { + const root = await mkdtemp(join(tmpdir(), "wspc-drive-sync-oversized-legacy-")) + await initDriveState(root, "lib_1") + await sparse(root, "big.bin", overLimit) + const api = mkApi([{ entries: [] }, { entries: [] }]) + await runDriveSyncOnce(root, api) + const state = await readDriveState(root) + const scanned = state.scan_cache!["big.bin"]! + state.upload_rejections = { + "big.bin": { + mtime_ms: scanned.mtime_ms, + size_bytes: scanned.size_bytes, + sha256: scanned.sha256, + code: "DRIVE_PATH_ERROR", + message: "HTTP 413", + cli_version: "0.0.0-older", + rejected_at: "2026-09-15T00:00:00.000Z", + }, + } + await writeDriveState(root, state) + + const summary = await runDriveSyncOnce(root, api) + + expect(uploadCount(api, "big.bin")).toBe(0) + expect(summary.path_errors).toEqual([tooLarge("big.bin")]) + expect((await readDriveState(root)).upload_rejections).toBeUndefined() + }) + + it("reports an oversized file once when a current rejection also matches", async () => { + const { root, api } = await oversizedLibrary("wspc-drive-sync-oversized-dedupe-", 2) + const state = await readDriveState(root) + const scanned = state.scan_cache!["big.bin"]! + state.upload_rejections = { + "big.bin": { + mtime_ms: scanned.mtime_ms, + size_bytes: scanned.size_bytes, + sha256: scanned.sha256, + code: "FILE_TOO_LARGE", + message: "HTTP 413", + cli_version: VERSION, + rejected_at: "2026-09-18T00:00:00.000Z", + }, + } + await writeDriveState(root, state) + + const summary = await runDriveSyncOnce(root, api) + + expect(summary.path_errors).toEqual([tooLarge("big.bin")]) + expect(summary.errors).toBe(1) + }) }) it("renders command summary and sets exit code for conflicts", async () => { From 3b8616a91db46b733c2288d2baad1cb8fea35149 Mon Sep 17 00:00:00 2001 From: Pocket Shark Date: Fri, 18 Sep 2026 20:30:06 +0800 Subject: [PATCH 5/6] refactor(drive): keep the per-file limit constant module-private --- src/handwritten/commands/drive/sync.ts | 2 +- test/handwritten/drive/sync.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/handwritten/commands/drive/sync.ts b/src/handwritten/commands/drive/sync.ts index 5cdbde6..0fecbeb 100644 --- a/src/handwritten/commands/drive/sync.ts +++ b/src/handwritten/commands/drive/sync.ts @@ -59,7 +59,7 @@ function emptySummary(): DriveSyncSummary { // Mirrors MAX_FILE_SIZE_BYTES in sadcoderlabs/wspc packages/drive/worker/src/limits.ts; // the server does not expose it, so a server change needs a CLI release. -export const DRIVE_MAX_FILE_SIZE_BYTES = 100 * 1024 * 1024 +const DRIVE_MAX_FILE_SIZE_BYTES = 100 * 1024 * 1024 export type DriveSyncProgress = (processed: number, total: number) => void diff --git a/test/handwritten/drive/sync.test.ts b/test/handwritten/drive/sync.test.ts index 181cfae..095432e 100644 --- a/test/handwritten/drive/sync.test.ts +++ b/test/handwritten/drive/sync.test.ts @@ -2021,7 +2021,7 @@ describe("drive sync once", () => { const uploadedBytes: number[] = [] api.uploadFile = async (id, path, body, digest, expectedEntryVersion) => { api.uploads.push({ id, path, sha256: digest, expectedEntryVersion }) - uploadedBytes.push(typeof body === "string" ? Buffer.byteLength(body) : body.byteLength) + uploadedBytes.push((body as ArrayBuffer).byteLength) return { entry: { ...entry(path, "", 1), content_sha256: digest, size_bytes: 104_857_600 }, result: "created", From b174505fb875ee3f3c4f3b481bdffed6eb7ef27e Mon Sep 17 00:00:00 2001 From: Pocket Shark Date: Fri, 18 Sep 2026 20:30:24 +0800 Subject: [PATCH 6/6] test(drive): drop redundant oversized rename test --- test/handwritten/drive/sync.test.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/test/handwritten/drive/sync.test.ts b/test/handwritten/drive/sync.test.ts index 095432e..98e46e9 100644 --- a/test/handwritten/drive/sync.test.ts +++ b/test/handwritten/drive/sync.test.ts @@ -2090,17 +2090,6 @@ describe("drive sync once", () => { expect(api.uploads).toEqual([]) }) - it("uploads a renamed file once it fits within the limit", async () => { - const { root, api } = await oversizedLibrary("wspc-drive-sync-oversized-rename-small-", 2) - await unlink(join(root, "big.bin")) - await writeFile(join(root, "renamed.bin"), "small") - - const summary = await runDriveSyncOnce(root, api) - - expect(summary.path_errors ?? []).toEqual([]) - expect(uploadCount(api, "renamed.bin")).toBe(1) - }) - it("replaces a rejection recorded by an older CLI with FILE_TOO_LARGE", async () => { const root = await mkdtemp(join(tmpdir(), "wspc-drive-sync-oversized-legacy-")) await initDriveState(root, "lib_1")