Skip to content

CLI request cloning can stall an early Edge Function response #6564

Description

@DennisKakar

Affected area

Edge Functions

Supabase CLI version

2.117.0

Operating system

macOS ARM64; Ubuntu CI also timed out in the same scenario

Installation method

None

Command

node --input-type=module (complete standalone command below)

Actual output

Early HTTP 400/401 rejection can remain pending until the caller's 15-second deadline. Standalone reproduction: clone + streamed source does not settle; no clone + streamed source settles. Full reproduction and observed results below.

Expected behavior

The rejection reaches the caller promptly, and forwarding resources settle without requiring an unbounded body drain.

Steps to reproduce

Reproduced on 2026-09-11.

Environment and observed behavior

Supabase CLI 2.117.0, Edge Runtime 1.74.3, and Node v24.14.0 for the standalone reproduction below. Actual local Edge HTTP qualification reproduced the failure on macOS ARM64. Ubuntu CI also timed out in the same scenario, but its original log did not identify the failing request.

A function rejects a POST before reading its body: HTTP 400 for a disallowed Origin or HTTP 401 for an invalid application credential. Some requests receive no HTTP response before the caller's 15-second deadline. A constant 128 KiB body reproduces the Origin failure. The invalid-credential failure snapshot showed no CPU-limit event or active database connection.

Expected: the rejection reaches the caller promptly, and forwarding resources settle without requiring an unbounded body drain.

Actual: the local forwarding path can remain pending although an early response is available. The standalone reproduction proves the stream-cancellation mechanism; matching it to the actual Edge timeout is a source-supported inference, not instrumentation of vendor internals. Hosted behavior has not been tested.

Relevant source path

  • CLI's prepareUserRequest unconditionally uses new Request(clonedURL, req.clone()); its caller then awaits worker.fetch(userReq). This matches the installed 2.117.0 template and remains in the current upstream source.
  • start in 2.117.0 uses the same startEdgeRuntimeContainer as functions serve.
  • Edge 1.74.3 UserWorker.fetch pipes the request body, then awaits Promise.allSettled([requestBodyPromise, responsePromise]) before returning the response.
  • The Rust response guard cancels body writes when the response arrives. pipeTo then propagates cancellation to the cloned branch; the original branch remains untouched. Streams tee cancellation semantics explain why that cancellation can remain pending.

Minimal reproduction

No network request, dependency installation, secret, database or file is needed. The source is finite: 128 KiB, delivered in 4 KiB chunks for streamed cases.

node --input-type=module <<'JS'
async function probe(clone, streamed) {
  let emitted = 0;
  let cancelled = false;
  const source = new ReadableStream({
    start(c) {
      if (!streamed) { c.enqueue(new Uint8Array(131072)); c.close(); emitted = 131072; }
    },
    pull(c) {
      if (emitted === 131072) return c.close();
      c.enqueue(new Uint8Array(4096)); emitted += 4096;
    },
    cancel() { cancelled = true; },
  });
  const original = new Request('http://localhost/example', {
    method: 'POST', duplex: 'half', body: source,
  });
  const forwarded = clone ? new Request(original.url, original.clone()) : original;
  const pipe = forwarded.body.pipeTo(new WritableStream({
    write() { throw new Error('body writer cancelled after early response'); },
  }));
  let settled = false;
  const forwarding = Promise.allSettled([pipe, Promise.resolve(new Response(null, { status: 400 }))])
    .then(() => { settled = true; });
  await new Promise(resolve => setTimeout(resolve, 100));
  console.log({ clone, streamed, settled, emitted, cancelled });
  if (clone) await original.body.cancel();
  await forwarding;
  console.log({ afterCleanup: true, settled, cancelled });
}
await probe(true, true);
await probe(false, true);
await probe(true, false);
JS

Observed before cleanup:

Clone Source Forwarding settled Bytes emitted Source cancelled
Yes Streamed No 12,288 No
No Streamed Yes 8,192 Yes
Yes Already closed Yes 131,072 No

Cancelling the unused original branch immediately settles the stalled first case. Every probe cleans up and the process exits successfully. These controls establish a cancellation/lifecycle failure, rather than slow processing of the body.

Requested upstream resolution

Please review ownership of the original request body during cloning and cancellation. No verified supported CLI switch or released correction was found. This report does not propose changing application admission limits, increasing timeouts, draining unlimited bodies or patching installed vendor code.

Crash report ID

No response

Docker and service versions

Additional context

No response

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions