Workers SDK Issue Reports

← Back to Dashboard

#7313 Hanging vitest test of worker that uses cache.defaults and returns HTMLRewriter

Download Reproduction
Recommendation:KEEP OPEN
Difficulty:medium
Reasoning:

Stream deadlock: cache.put() in waitUntil() and HTMLRewriter.transform() share underlying body stream. waitOnExecutionContext() blocks on cache.put() which waits for stream data that won't flow until HTMLRewriter output is consumed.

Suggested Action:

Add timeout with warning to waitOnExecutionContext(); document limitation; investigate Response.clone() fix

Analysis Report

Issue Review: cloudflare/workers-sdk#7313

Summary

Vitest tests hang when a worker uses both caches.default (with cache.put() in waitUntil()) and returns a response transformed by HTMLRewriter.

Findings

  • Created: 2024-11-21
  • Updated: 2025-10-30
  • Version: wrangler 3.85.0, @cloudflare/vitest-pool-workers 0.5.27 → wrangler 4.60.0, @cloudflare/vitest-pool-workers 0.8.x
  • Component: vitest-pool-workers, miniflare (cache/HTMLRewriter)
  • Labels: bug, vitest
  • Comments: 0

Key Evidence

  • Reproduction confirmed with latest versions (wrangler 4.60.0, vitest 3.2.4, @cloudflare/vitest-pool-workers 0.8.0)
  • No related PR merges found referencing this issue
  • No changelog entries found for this specific issue
  • Isolated testing confirms:
    • HTMLRewriter alone: PASSES
    • Cache alone: PASSES
    • Both combined: HANGS

Root Cause Analysis

The issue is a stream consumption deadlock between cache.put() and HTMLRewriter.transform():

  1. In the worker code, response.clone() is called before passing response to HTMLRewriter.transform()
  2. The cache.put() operation (in waitUntil()) needs to consume the cloned response body stream
  3. HTMLRewriter.transform() creates a transform stream that wraps the original response body
  4. Due to how Response.clone() works with streaming bodies, both the original and cloned Response share the same underlying body source until one starts being consumed
  5. When waitOnExecutionContext() awaits the waitUntil() promises (which includes cache.put()), the cache operation tries to read the body stream
  6. However, the HTMLRewriter transform stream is waiting for chunks from the same underlying source
  7. The test doesn't consume the response body until after waitOnExecutionContext() returns, creating a deadlock:
    • waitOnExecutionContext() waits for cache.put() to complete
    • cache.put() waits for body stream chunks
    • Body stream chunks won't flow until HTMLRewriter.transform() output is consumed
    • But the test hasn't called response.text() yet because it's blocked on waitOnExecutionContext()

Code path in packages/vitest-pool-workers/src/worker/events.ts:29-44:

export async function waitOnExecutionContext(ctx: unknown): Promise<void> {
  // ...
  return waitForWaitUntil(ctx[kWaitUntil]); // Waits for cache.put() to complete
}

The problematic pattern in user code:

// response.clone() creates a shared body source with the original
ctx.waitUntil(cache.put(cacheKey, response.clone()));

// HTMLRewriter wraps the same underlying body stream
return new HTMLRewriter().on('font', handler).transform(response);

Proposed Solution

There are several possible approaches:

Option A: Documentation/Workaround (Easy)
Document that users should consume the response body before returning when using both cache and HTMLRewriter:

// Workaround: Consume body before waitUntil completion
const html = `<html><body><font>${ENVIRONMENT}</font></body></html>`;
const response = new Response(html, { headers: { 'content-type': 'text/html' } });

// Clone from non-streaming response is safe
ctx.waitUntil(cache.put(cacheKey, response.clone()));

return new HTMLRewriter().on('font', handler).transform(response);

Option B: Modify waitOnExecutionContext behavior (Medium)
Change waitOnExecutionContext() to not block on waitUntil() promises that involve stream operations, or add a timeout with a warning.

Option C: Fix Response.clone() behavior for HTMLRewriter (Hard)
This would require changes in workerd to ensure Response.clone() creates a fully independent copy of the body when used before HTMLRewriter.transform().

Option D: Buffer HTMLRewriter output (Medium)
Add an option or automatic detection in vitest-pool-workers to buffer HTMLRewriter responses before waitOnExecutionContext() processes them.

Recommended Fix

The most practical fix is a combination:

  1. Immediate: Add documentation warning about this pattern
  2. Short-term: Modify waitOnExecutionContext() to add a timeout with a helpful error message when detecting potential stream deadlocks
  3. Long-term: Investigate whether Response.clone() can be made safer in the miniflare/workerd context

Example implementation for timeout (packages/vitest-pool-workers/src/worker/wait-until.ts):

export async function waitForWaitUntil(
  /* mut */ waitUntil: unknown[]
): Promise<void> {
  const errors: unknown[] = [];
  const TIMEOUT_MS = 10000; // 10 seconds

  while (waitUntil.length > 0) {
    const promises = waitUntil.splice(0);
    const timeoutPromise = new Promise<'timeout'>((resolve) => 
      setTimeout(() => resolve('timeout'), TIMEOUT_MS)
    );
    
    const results = await Promise.race([
      Promise.allSettled(promises),
      timeoutPromise
    ]);
    
    if (results === 'timeout') {
      console.warn(
        'waitOnExecutionContext() timed out waiting for waitUntil() promises.\n' +
        'This may indicate a stream deadlock. If using cache.put() with HTMLRewriter,\n' +
        'ensure the response body is consumed before waitOnExecutionContext() is called.'
      );
      // Continue without blocking
      break;
    }
    
    for (const result of results) {
      if (result.status === "rejected") {
        errors.push(result.reason);
      }
    }
  }
  // ... rest of error handling
}

Recommendation

Status: KEEP OPEN

Reasoning: The bug is confirmed reproducible with current versions. This is a legitimate stream handling issue that affects users combining cache API with HTMLRewriter in vitest tests. The issue has been open for over a year with no fix, affecting a valid use case.

Action:

  1. Implement a timeout with warning message in waitOnExecutionContext()
  2. Add documentation about this known limitation
  3. Consider deeper fix in workerd/miniflare for Response.clone() behavior

Implementation Details

Difficulty: Medium

Justification:

  • The timeout/warning solution is straightforward to implement
  • Understanding the root cause required deep investigation of stream handling
  • A complete fix would require changes across multiple components (workerd, miniflare, vitest-pool-workers)

Files to modify:

  1. packages/vitest-pool-workers/src/worker/wait-until.ts - Add timeout logic
  2. packages/vitest-pool-workers/README.md or docs - Document the limitation
  3. Optionally: packages/miniflare/src/workers/cache/cache.worker.ts - Add stream buffering

Testing recommendations:

  1. Add a test case that combines cache.put() with HTMLRewriter to the vitest-pool-workers test suite
  2. Verify the timeout warning is helpful and doesn't break valid use cases
  3. Test with various response sizes to ensure buffering doesn't cause memory issues

Suggested Comment

We've confirmed this issue is still reproducible with the latest versions (wrangler 4.60.0, @cloudflare/vitest-pool-workers 0.8.x).

Root cause: This is a stream consumption deadlock. When response.clone() is called before HTMLRewriter.transform(), both operations share the underlying body stream source. The waitOnExecutionContext() function blocks waiting for cache.put() to complete, but cache.put() is waiting for body stream data that won't flow until the HTMLRewriter output is consumed - which happens after waitOnExecutionContext() returns.

Workaround: Create the response from a string/buffer instead of transforming a streaming response when using cache:

const html = `<html><body>...</body></html>`;
const response = new Response(html, { headers: { 'content-type': 'text/html' } });
ctx.waitUntil(cache.put(cacheKey, response.clone()));
return new HTMLRewriter().on('selector', handler).transform(response);

We're looking into adding a timeout with a helpful warning message to waitOnExecutionContext() to make this issue easier to diagnose.

Notes & Feedback (0)

No notes yet.

Add Note