#7313 Hanging vitest test of worker that uses cache.defaults and returns HTMLRewriter
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.
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():
- In the worker code,
response.clone()is called before passingresponsetoHTMLRewriter.transform() - The
cache.put()operation (inwaitUntil()) needs to consume the cloned response body stream HTMLRewriter.transform()creates a transform stream that wraps the original response body- 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 - When
waitOnExecutionContext()awaits thewaitUntil()promises (which includescache.put()), the cache operation tries to read the body stream - However, the
HTMLRewritertransform stream is waiting for chunks from the same underlying source - The test doesn't consume the response body until after
waitOnExecutionContext()returns, creating a deadlock:waitOnExecutionContext()waits forcache.put()to completecache.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 onwaitOnExecutionContext()
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:
- Immediate: Add documentation warning about this pattern
- Short-term: Modify
waitOnExecutionContext()to add a timeout with a helpful error message when detecting potential stream deadlocks - 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:
- Implement a timeout with warning message in
waitOnExecutionContext() - Add documentation about this known limitation
- 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:
packages/vitest-pool-workers/src/worker/wait-until.ts- Add timeout logicpackages/vitest-pool-workers/README.mdor docs - Document the limitation- Optionally:
packages/miniflare/src/workers/cache/cache.worker.ts- Add stream buffering
Testing recommendations:
- Add a test case that combines cache.put() with HTMLRewriter to the vitest-pool-workers test suite
- Verify the timeout warning is helpful and doesn't break valid use cases
- 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 beforeHTMLRewriter.transform(), both operations share the underlying body stream source. ThewaitOnExecutionContext()function blocks waiting forcache.put()to complete, butcache.put()is waiting for body stream data that won't flow until theHTMLRewriteroutput is consumed - which happens afterwaitOnExecutionContext()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.