From 5b74cb68a570ae1481d653a8ede10fcd1b88cb32 Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Tue, 15 Sep 2026 22:21:02 -0700 Subject: [PATCH 1/2] fix(packages): reclaim unadopted multipart uploads --- convex/httpApiV1.handlers.test.ts | 121 ++++++++++++++++++++++++++++++ convex/httpApiV1/packagesV1.ts | 62 +++++++++++++-- convex/packages.public.test.ts | 43 ++++++++++- convex/packages.ts | 94 ++++++++++++++--------- specs/plans/plugins.md | 9 +++ 5 files changed, 284 insertions(+), 45 deletions(-) diff --git a/convex/httpApiV1.handlers.test.ts b/convex/httpApiV1.handlers.test.ts index 241d956004..a9d8878c79 100644 --- a/convex/httpApiV1.handlers.test.ts +++ b/convex/httpApiV1.handlers.test.ts @@ -16956,6 +16956,126 @@ describe("httpApiV1 handlers", () => { expect(runAction).not.toHaveBeenCalled(); }); + it("waits for late multipart stores before cleaning a failed request", async () => { + vi.mocked(requirePackagePublishAuth).mockResolvedValue({ + kind: "user", + userId: "users:1", + user: { _id: "users:1", handle: "p" }, + } as never); + let finishLateStore!: (id: string) => void; + const lateStore = new Promise((resolve) => { + finishLateStore = resolve; + }); + const store = vi + .fn() + .mockRejectedValueOnce(new Error("storage unavailable")) + .mockImplementationOnce(() => lateStore); + const remove = vi.fn().mockResolvedValue(undefined); + const runAction = vi.fn(); + const form = packagePublishForm(packagePublishMetadata()); + form.append("files", new File(["first"], "first.txt")); + form.append("files", new File(["late"], "late.txt")); + const responsePromise = __handlers.publishPackageV1Handler( + makeCtx({ + runMutation: vi.fn().mockResolvedValue(okRate()), + runAction, + storage: { store, delete: remove }, + }), + new Request("https://example.com/api/v1/packages", { method: "POST", body: form }), + ); + await vi.waitFor(() => expect(store).toHaveBeenCalledTimes(2)); + expect(remove).not.toHaveBeenCalled(); + finishLateStore("storage:late"); + const response = await responsePromise; + expect(response.status).toBe(400); + expect(await response.text()).toContain("storage unavailable"); + expect(remove).toHaveBeenCalledExactlyOnceWith("storage:late"); + expect(runAction).not.toHaveBeenCalled(); + }); + + it.each([false, true])( + "cleans partial tarball extraction without deleting reused artifacts (staged: %s)", + async (staged) => { + vi.mocked(requirePackagePublishAuth).mockResolvedValue({ + kind: "user", + userId: "users:1", + user: { _id: "users:1", handle: "p" }, + } as never); + const pack = npmPackFixture({ + "package/package.json": JSON.stringify({ name: "demo-plugin", version: "1.0.0" }), + "package/openclaw.plugin.json": "{}", + "package/README.md": "readme", + }); + let finishLateStore!: (id: string) => void; + const lateStore = new Promise((resolve) => { + finishLateStore = resolve; + }); + const store = vi.fn(); + if (!staged) store.mockResolvedValueOnce("storage:new-tarball"); + store + .mockRejectedValueOnce(new Error("partial extraction")) + .mockImplementationOnce(() => lateStore) + .mockResolvedValueOnce("storage:early"); + const remove = vi.fn().mockResolvedValue(undefined); + const runAction = vi.fn(); + const form = packagePublishForm(packagePublishMetadata()); + if (staged) { + form.set("clawpack", "storage:retained-tarball"); + form.set("clawpackUploadTicket", "packagePublishUploadTickets:1"); + } else form.set("clawpack", new File([bytesToArrayBuffer(pack)], "demo.tgz")); + const responsePromise = __handlers.publishPackageV1Handler( + makeCtx({ + runMutation: vi.fn().mockResolvedValue(okRate()), + runAction, + storage: { + store, + delete: remove, + get: vi.fn().mockResolvedValue(new Blob([bytesToArrayBuffer(pack)])), + }, + }), + new Request("https://example.com/api/v1/packages", { method: "POST", body: form }), + ); + await vi.waitFor(() => expect(store).toHaveBeenCalledTimes(staged ? 3 : 4)); + expect(remove).not.toHaveBeenCalled(); + finishLateStore("storage:late"); + expect((await responsePromise).status).toBe(400); + expect(remove.mock.calls.flat().sort((a, b) => String(a).localeCompare(String(b)))).toEqual( + [...(staged ? [] : ["storage:new-tarball"]), "storage:early", "storage:late"].sort((a, b) => + a.localeCompare(b), + ), + ); + expect(runAction).not.toHaveBeenCalled(); + }, + ); + + it("does not infer deletion authority from a failed publication RPC", async () => { + vi.mocked(requirePackagePublishAuth).mockResolvedValue({ + kind: "user", + userId: "users:1", + user: { _id: "users:1", handle: "p" }, + } as never); + const remove = vi.fn(); + const runAction = vi.fn().mockRejectedValue(new Error("RPC response lost")); + const form = packagePublishForm(packagePublishMetadata()); + form.append("files", new File(["{}"], "openclaw.plugin.json")); + const response = await __handlers.publishPackageV1Handler( + makeCtx({ + runMutation: vi.fn().mockResolvedValue(okRate()), + runAction, + storage: { store: vi.fn().mockResolvedValue("storage:request-file"), delete: remove }, + }), + new Request("https://example.com/api/v1/packages", { method: "POST", body: form }), + ); + expect(response.status).toBe(400); + expect(remove).not.toHaveBeenCalled(); + expect(runAction).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + requestStorageIds: ["storage:request-file"], + }), + ); + }); + it("multipart package publish ignores macOS junk files", async () => { vi.mocked(getOptionalApiTokenUserId).mockResolvedValue("users:1" as never); vi.mocked(requirePackagePublishAuth).mockResolvedValue({ @@ -17383,6 +17503,7 @@ describe("httpApiV1 handlers", () => { expect(runAction).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ + requestStorageIds: ["storage:1", "storage:2", "storage:3"], payload: expect.objectContaining({ artifact: expect.objectContaining({ kind: "npm-pack", diff --git a/convex/httpApiV1/packagesV1.ts b/convex/httpApiV1/packagesV1.ts index 754586f43e..822f9301cd 100644 --- a/convex/httpApiV1/packagesV1.ts +++ b/convex/httpApiV1/packagesV1.ts @@ -1391,12 +1391,34 @@ function bytesToArrayBuffer(bytes: Uint8Array) { return copy.buffer; } +async function storeRequestPackageBlob( + ctx: ActionCtx, + requestStorageIds: Set>, + blob: Blob, +) { + const storageId = await ctx.storage.store(blob); + requestStorageIds.add(storageId); + return storageId; +} + +async function settlePackageFileStores(stores: Array>) { + // A rejected store does not cancel its siblings. Wait before reclaiming their blobs. + const results = await Promise.allSettled(stores); + return results.map((result) => { + if (result.status === "rejected") throw result.reason; + return result.value; + }); +} + async function storeClawPackFile( ctx: ActionCtx, entry: { path: string; bytes: Uint8Array }, + requestStorageIds: Set>, ): Promise { const contentType = defaultStoredPackageContentType(); - const storageId = await ctx.storage.store( + const storageId = await storeRequestPackageBlob( + ctx, + requestStorageIds, new Blob([bytesToArrayBuffer(entry.bytes)], { type: contentType }), ); return { @@ -1417,12 +1439,17 @@ const CLAWPACK_STORE_BATCH_FILES = 16; async function storeClawPackFiles( ctx: ActionCtx, entries: Array<{ path: string; bytes: Uint8Array }>, + requestStorageIds: Set>, ) { const files: StoredPackagePublishFile[] = []; let batch: Array<{ path: string; bytes: Uint8Array }> = []; let batchBytes = 0; const flush = async () => { - files.push(...(await Promise.all(batch.map((entry) => storeClawPackFile(ctx, entry))))); + files.push( + ...(await settlePackageFileStores( + batch.map((entry) => storeClawPackFile(ctx, entry, requestStorageIds)), + )), + ); batch = []; batchBytes = 0; }; @@ -1441,13 +1468,16 @@ async function storeClawPackFiles( async function storeUploadedPackageFile( ctx: ActionCtx, entry: File, + requestStorageIds: Set>, ): Promise { if (entry.size > MAX_PUBLISH_FILE_BYTES) { throw new Error(getPublishFileSizeError(entry.name)); } const buffer = new Uint8Array(await entry.arrayBuffer()); const contentType = normalizeContentType(entry.type) ?? defaultStoredPackageContentType(); - const storageId = await ctx.storage.store( + const storageId = await storeRequestPackageBlob( + ctx, + requestStorageIds, new Blob([bytesToArrayBuffer(buffer)], { type: contentType }), ); return { @@ -1530,6 +1560,7 @@ async function buildPackagePublishRequestFromClawPack( parsed: ParsedPackageClawPack, artifactBytes: Uint8Array, artifactStorageId: Id<"_storage">, + requestStorageIds: Set>, ): Promise { if (parsed.unpackedSize > MAX_PUBLISH_TOTAL_BYTES) { throw new Error(getPublishTotalSizeError("package")); @@ -1546,7 +1577,7 @@ async function buildPackagePublishRequestFromClawPack( npmUnpackedSize: parsed.unpackedSize, npmFileCount: parsed.fileCount, }; - const files = await storeClawPackFiles(ctx, parsed.entries); + const files = await storeClawPackFiles(ctx, parsed.entries, requestStorageIds); return { ...metadata, files, artifact }; } @@ -1597,6 +1628,7 @@ async function parseMultipartPackagePublish( ctx: ActionCtx, auth: PackagePublishAuth, request: Request, + requestStorageIds: Set>, ): Promise { const form = await request.formData(); for (const field of form.keys()) { @@ -1645,6 +1677,7 @@ async function parseMultipartPackagePublish( parsed, artifactBytes, tarballPart.storageId, + requestStorageIds, ); } @@ -1664,7 +1697,9 @@ async function parseMultipartPackagePublish( const artifactBytes = new Uint8Array(await tarballEntry.arrayBuffer()); const parsed = await parseClawPack(artifactBytes); assertClawPackPublicationIdentity(metadata, parsed); - const artifactStorageId = await ctx.storage.store( + const artifactStorageId = await storeRequestPackageBlob( + ctx, + requestStorageIds, new Blob([bytesToArrayBuffer(artifactBytes)], { type: "application/octet-stream" }), ); return await buildPackagePublishRequestFromClawPack( @@ -1673,6 +1708,7 @@ async function parseMultipartPackagePublish( parsed, artifactBytes, artifactStorageId, + requestStorageIds, ); } @@ -1687,8 +1723,8 @@ async function parseMultipartPackagePublish( } const packageFileParts = fileParts.filter((entry) => !isMacJunkPath(entry.name)); - const files = await Promise.all( - packageFileParts.map((entry) => storeUploadedPackageFile(ctx, entry)), + const files = await settlePackageFileStores( + packageFileParts.map((entry) => storeUploadedPackageFile(ctx, entry, requestStorageIds)), ); if (files.length === 0) throw new Error("files required"); return { ...metadata, files }; @@ -2669,24 +2705,34 @@ export async function publishPackageV1Handler(ctx: ActionCtx, request: Request) const auth = await requirePackagePublishAuthOrResponse(ctx, request, rate.headers); if (!auth.ok) return auth.response; + const requestStorageIds = new Set>(); + let publicationOwnsCleanup = false; try { const contentType = request.headers.get("content-type") ?? ""; if (!contentType.includes("multipart/form-data")) { return text("Package publish requires multipart/form-data", 415, rate.headers); } - const payload = await parseMultipartPackagePublish(ctx, auth.auth, request); + const payload = await parseMultipartPackagePublish(ctx, auth.auth, request, requestStorageIds); + // After dispatch, only the action can know whether a release adopted these blobs. + // An ambiguous RPC failure is not authority for HTTP cleanup. + publicationOwnsCleanup = true; const result = auth.auth.kind === "user" ? await runActionRef(ctx, internalRefs.packages.publishPackageForUserInternal, { actorUserId: auth.auth.userId, payload, + requestStorageIds: [...requestStorageIds], }) : await runActionRef(ctx, internalRefs.packages.publishPackageForTrustedPublisherInternal, { publishTokenId: auth.auth.publishToken._id, payload, + requestStorageIds: [...requestStorageIds], }); return json(result, 200, rate.headers); } catch (error) { + if (!publicationOwnsCleanup) { + await Promise.allSettled([...requestStorageIds].map((id) => ctx.storage.delete(id))); + } return packagePublishErrorToResponse(error, rate.headers); } } diff --git a/convex/packages.public.test.ts b/convex/packages.public.test.ts index 763e875fc6..36eaccede1 100644 --- a/convex/packages.public.test.ts +++ b/convex/packages.public.test.ts @@ -463,6 +463,7 @@ const publishPackageForUserInternalHandler = ( { actorUserId: string; payload: unknown; + requestStorageIds?: string[]; }, unknown > @@ -475,6 +476,7 @@ const publishPackageForTrustedPublisherInternalHandler = ( { publishTokenId: string; payload: unknown; + requestStorageIds?: string[]; }, unknown > @@ -11042,6 +11044,36 @@ describe("packages public queries", () => { ); }); + it("reclaims only request-created blobs when package validation rejects before adoption", async () => { + const remove = vi.fn().mockResolvedValue(undefined); + await expect( + publishPackageForUserInternalHandler({ storage: { delete: remove } } as never, { + actorUserId: "users:owner", + requestStorageIds: ["storage:request-file"], + payload: { invalid: true, artifact: { storageId: "storage:reused-tarball" } }, + }), + ).rejects.toThrow(/Package publish payload/i); + expect(remove).toHaveBeenCalledExactlyOnceWith("storage:request-file"); + }); + + it("reclaims request files when trusted authorization expires before publication", async () => { + const remove = vi.fn().mockResolvedValue(undefined); + await expect( + publishPackageForTrustedPublisherInternalHandler( + { + runQuery: vi.fn().mockResolvedValue(null), + storage: { delete: remove }, + } as never, + { + publishTokenId: "packagePublishTokens:expired", + requestStorageIds: ["storage:request-file"], + payload: { artifact: { storageId: "storage:reused-tarball" } }, + }, + ), + ).rejects.toThrow("Trusted publish token is missing or expired"); + expect(remove).toHaveBeenCalledExactlyOnceWith("storage:request-file"); + }); + it("validates package publish payloads inside the action path", async () => { await expect( publishPackageForUserInternalHandler({} as never, { @@ -12558,13 +12590,14 @@ describe("packages public queries", () => { scheduler: { runAfter: vi.fn(), }, - storage: makePackageManifestStorage(), + storage: { ...makePackageManifestStorage(), delete: vi.fn() }, }; try { await expect( publishPackageForTrustedPublisherInternalHandler(ctx as never, { publishTokenId: "packagePublishTokens:1", + requestStorageIds: [packageManifestFile.storageId], payload: { name: "demo-plugin", family: "bundle-plugin", @@ -12591,6 +12624,7 @@ describe("packages public queries", () => { createdNewParent: false, }), ); + expect(ctx.storage.delete).not.toHaveBeenCalled(); expect(runMutation).not.toHaveBeenCalledWith(expect.anything(), { tokenId: "packagePublishTokens:1", }); @@ -12849,7 +12883,7 @@ describe("packages public queries", () => { }); it.each(["rejected", "reused", "created", "followup-failure"] as const)( - "keeps legacy ZIP ownership after %s insertion", + "keeps generated ZIP and request-file ownership after %s insertion", async (outcome) => { const storedIds: string[] = []; const deletedIds: string[] = []; @@ -12933,6 +12967,7 @@ describe("packages public queries", () => { const publication = publishPackageForTrustedPublisherInternalHandler(ctx as never, { publishTokenId: "packagePublishTokens:1", + requestStorageIds: [packageManifestFile.storageId], payload: { name: "demo-plugin", family: "bundle-plugin", @@ -12957,7 +12992,9 @@ describe("packages public queries", () => { expect(storedIds).toEqual(["storage:legacy-zip"]); expect(deletedIds).toEqual( - outcome === "rejected" || outcome === "reused" ? ["storage:legacy-zip"] : [], + outcome === "rejected" || outcome === "reused" + ? ["storage:legacy-zip", packageManifestFile.storageId] + : [], ); }, ); diff --git a/convex/packages.ts b/convex/packages.ts index 627cc8243c..9f1ca12492 100644 --- a/convex/packages.ts +++ b/convex/packages.ts @@ -698,6 +698,7 @@ type PackagePublishAuthContext = type PackageTrustedPublisherDoc = Doc<"packageTrustedPublishers">; type PackagePublishOptions = { stagePrePublicationChecks?: boolean; + onFilesAdopted?: () => void; }; type PackageDoc = Doc<"packages">; type PublicPackageListItem = { @@ -9339,6 +9340,7 @@ async function publishPackageImpl( const legacyZipStorageId = await storeLegacyZipIfNeeded(); try { const { reusedExistingRelease, ...result } = await insert(); + if (!reusedExistingRelease) options.onFilesAdopted?.(); if (reusedExistingRelease && legacyZipStorageId) { // An idempotent retry keeps the old archive instead of adopting this ZIP. await ctx.storage.delete(legacyZipStorageId).catch(() => undefined); @@ -9729,17 +9731,37 @@ function toPackageInspectorPublishResponseFinding( }; } +async function withRequestPackageStorage( + ctx: Pick, + requestStorageIds: Id<"_storage">[] | undefined, + publish: (onFilesAdopted: () => void) => Promise, +) { + let adopted = false; + try { + return await publish(() => { + adopted = true; + }); + } finally { + // These IDs come only from the HTTP request's stores, never from reused tickets. + // Successful retries may reuse old rows without adopting any of these new files. + if (!adopted && requestStorageIds?.length) { + await Promise.allSettled(requestStorageIds.map((id) => ctx.storage.delete(id))); + } + } +} + export const publishPackageForUserInternal = internalAction({ args: { actorUserId: v.id("users"), payload: v.any(), + requestStorageIds: v.optional(v.array(v.id("_storage"))), }, handler: async (ctx, args) => { - return await publishPackageImpl( - ctx, - { kind: "user", actorUserId: args.actorUserId }, - args.payload, - { stagePrePublicationChecks: stagedPrePublicationPublishesEnabled() }, + return await withRequestPackageStorage(ctx, args.requestStorageIds, (onFilesAdopted) => + publishPackageImpl(ctx, { kind: "user", actorUserId: args.actorUserId }, args.payload, { + stagePrePublicationChecks: stagedPrePublicationPublishesEnabled(), + onFilesAdopted, + }), ); }, }); @@ -9911,38 +9933,42 @@ export const publishPackageForTrustedPublisherInternal = internalAction({ args: { publishTokenId: v.id("packagePublishTokens"), payload: v.any(), + requestStorageIds: v.optional(v.array(v.id("_storage"))), }, handler: async (ctx, args) => { - const publishToken = await runQueryRef | null>( - ctx, - internalRefs.packagePublishTokens.getByIdInternal, - { tokenId: args.publishTokenId }, - ); - if ( - !publishToken || - publishToken.revokedAt || - publishToken.consumedAt || - publishToken.expiresAt <= Date.now() - ) { - throw new ConvexError("Trusted publish token is missing or expired"); - } - if ((publishToken.scope ?? "publish") !== "publish") { - throw new ConvexError("Trusted upload token cannot authorize package publication"); - } - assertOpenClawPublishAuthorizationVersion(publishToken); - const trustedPublisher = await runQueryRef( - ctx, - internalRefs.packages.getTrustedPublisherByPackageIdInternal, - { packageId: publishToken.packageId }, - ); - if (!doesTrustedPublisherMatchPublishToken(trustedPublisher, publishToken)) { - throw new ConvexError( - "Trusted publish token no longer matches the current package trusted publisher", + return await withRequestPackageStorage(ctx, args.requestStorageIds, async (onFilesAdopted) => { + const publishToken = await runQueryRef | null>( + ctx, + internalRefs.packagePublishTokens.getByIdInternal, + { tokenId: args.publishTokenId }, ); - } - return await publishPackageImpl(ctx, { kind: "github-actions", publishToken }, args.payload, { - stagePrePublicationChecks: - publishToken.authorizationVersion === 2 || stagedPrePublicationPublishesEnabled(), + if ( + !publishToken || + publishToken.revokedAt || + publishToken.consumedAt || + publishToken.expiresAt <= Date.now() + ) { + throw new ConvexError("Trusted publish token is missing or expired"); + } + if ((publishToken.scope ?? "publish") !== "publish") { + throw new ConvexError("Trusted upload token cannot authorize package publication"); + } + assertOpenClawPublishAuthorizationVersion(publishToken); + const trustedPublisher = await runQueryRef( + ctx, + internalRefs.packages.getTrustedPublisherByPackageIdInternal, + { packageId: publishToken.packageId }, + ); + if (!doesTrustedPublisherMatchPublishToken(trustedPublisher, publishToken)) { + throw new ConvexError( + "Trusted publish token no longer matches the current package trusted publisher", + ); + } + return await publishPackageImpl(ctx, { kind: "github-actions", publishToken }, args.payload, { + onFilesAdopted, + stagePrePublicationChecks: + publishToken.authorizationVersion === 2 || stagedPrePublicationPublishesEnabled(), + }); }); }, }); diff --git a/specs/plans/plugins.md b/specs/plans/plugins.md index 1c457801e6..c6e62d860e 100644 --- a/specs/plans/plugins.md +++ b/specs/plans/plugins.md @@ -72,6 +72,15 @@ that ownership. Staged retries resolve existing attempts before insertion. A concurrent pending insertion that finds an existing version rejects instead of creating an attempt that points at a discarded candidate ZIP. +Multipart package requests track only blobs created by that request. Parsing +waits for all in-flight stores before cleaning a failure. Staged upload-ticket +artifacts are reusable and never enter request-local cleanup. When HTTP dispatches +publication, it hands these IDs to the internal action; an ambiguous RPC failure +is not permission for HTTP to delete them. The action reclaims unadopted files on +failure or successful reuse, and relinquishes cleanup immediately after a new +published or pending release commits, before later fallible work. Pending-release +compensation remains responsible for its own adopted artifacts. + ### Portable plugin icons Plugin publication resolves only the fixed `assets/icon.png` path used by OpenClaw. From e17c766ce74a488cdae73dbf977f99d356328b16 Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Tue, 15 Sep 2026 22:58:59 -0700 Subject: [PATCH 2/2] fix(ci): bound local-auth isolate concurrency --- scripts/playwright-local-auth-config.test.ts | 9 ++++++++- scripts/playwright-local-auth-config.ts | 4 +++- specs/ci.md | 8 ++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/scripts/playwright-local-auth-config.test.ts b/scripts/playwright-local-auth-config.test.ts index e2d01fe4ff..99788ab442 100644 --- a/scripts/playwright-local-auth-config.test.ts +++ b/scripts/playwright-local-auth-config.test.ts @@ -11,14 +11,21 @@ import { describe("playwright local-auth runner config", () => { it("sets the backend transport timeout and bounded, cache-preferring npm fetches", () => { - expect(buildLocalAuthBackendEnv()).toEqual({ + expect(buildLocalAuthBackendEnv({})).toEqual({ HTTP_SERVER_TIMEOUT_SECONDS: "900", + FUNRUN_ISOLATE_ACTIVE_THREADS: "2", npm_config_prefer_offline: "true", npm_config_fetch_timeout: "60000", npm_config_fetch_retries: "5", }); }); + it("preserves an explicit unlimited isolate override for runtime diagnosis", () => { + expect(buildLocalAuthBackendEnv({ FUNRUN_ISOLATE_ACTIVE_THREADS: "0" })).toMatchObject({ + FUNRUN_ISOLATE_ACTIVE_THREADS: "0", + }); + }); + it("defaults local-auth Convex to the anonymous deployment marker", () => { expect(resolveLocalAuthDeployment(undefined, null)).toBe("anonymous:anonymous-agent"); expect(resolveLocalAuthDeployment(undefined, undefined)).toBe("anonymous:anonymous-agent"); diff --git a/scripts/playwright-local-auth-config.ts b/scripts/playwright-local-auth-config.ts index 0b14dec4d3..c64f62b1d1 100644 --- a/scripts/playwright-local-auth-config.ts +++ b/scripts/playwright-local-auth-config.ts @@ -21,11 +21,13 @@ export type LocalAuthRunnerConfig = { playwrightArgs: string[]; }; -export function buildLocalAuthBackendEnv() { +export function buildLocalAuthBackendEnv(env: RunnerEnv = process.env) { // A 300s backend 408 makes the CLI retry into the executor's shared build_deps directory. // 900s exceeds its 605s build_deps cap, so a stuck install hits the executor timeout first. return { HTTP_SERVER_TIMEOUT_SECONDS: String(LOCAL_AUTH_BACKEND_HTTP_TIMEOUT_SECONDS), + // Bound simultaneous V8 work; queued permits do not consume the 1s UDF watchdog. + FUNRUN_ISOLATE_ACTIVE_THREADS: env.FUNRUN_ISOLATE_ACTIVE_THREADS ?? "2", npm_config_prefer_offline: "true", npm_config_fetch_timeout: "60000", npm_config_fetch_retries: "5", diff --git a/specs/ci.md b/specs/ci.md index 63b37f53d1..76c6ada9db 100644 --- a/specs/ci.md +++ b/specs/ci.md @@ -77,6 +77,14 @@ set before the first push. Application readiness checks never republish code, and no development watcher can push again while the app builds. A persistent launcher retains ownership of the backend process group through cleanup. +The disposable backend defaults `FUNRUN_ISOLATE_ACTIVE_THREADS` to `2` before +bootstrap, limiting simultaneous V8 execution on small runners. Convex pauses +the user watchdog while a request waits for an execution permit; the one-second +UDF limit and existing system and admission limits remain unchanged. This reduces +CPU contention without serializing whole requests or changing production +configuration. An explicit process-environment override, +including `0` for the upstream unlimited default, is preserved for diagnosis. + The first push builds the external dependencies for Convex `"use node"` functions from their installed package versions. A slow cold npm install can exceed the backend's default 300-second HTTP timeout: its 408 response prompts a CLI retry