Skip to content

Durable object hanging tests while preventing timeouts #11122

@dairmhorglascoisanbhothar

Description

What versions & operating system are you using?

Node v22, Vitest v3, Miniflare v4.20251008.0

Please provide a link to a minimal reproduction

No response

Describe the Bug

Calling Durable Object method that never resolves hangs tests and can not be raced against using Promise.race()

It does however allow for racing when proxied via a regular worker.

Tests that have .skip() are the ones that hang

/** *
 * Issue: Calling a Durable Object method that blocks internally (waiting on a promise)
 * in test makes the test hang
 *
 * Expected: The blockedOp() call should allow Promise.race() to timeout first when release() is never called.
 *
 * Actual: Regardless of not being awaited the call to the .blockedOp hanges the test as well as prevents the vitest from timing out.
 */

import { it, expect, afterEach, beforeEach } from 'vitest';
import { Miniflare } from 'miniflare';

let mf: Miniflare;

beforeEach(async () => {
	mf = new Miniflare({
		workers: [
			{
				name: 'do-worker',
				modules: true,
				script: `
					import { DurableObject } from 'cloudflare:workers';

					export class BlockingDO extends DurableObject {
						locks = new Map();

						async blockedOp(n, lock) {
							console.log('blockedOp called, waiting for lock:', lock);
							await new Promise((resolve) => {
								this.locks.set(lock, () => {
									console.log('lock released:', lock);
									resolve(lock);
								});
							});
							console.log('blockedOp completing');
							return n + 2;
						}

						async release(lock) {
							console.log('release called for:', lock);
							const releaseFn = this.locks.get(lock);
							if (releaseFn) {
								releaseFn();
								this.locks.delete(lock);
							}
						}
					}
				`,
				durableObjects: {
					BLOCKING_DO: 'BlockingDO',
				},
				compatibilityDate: '2025-04-23',
			},
			{
				name: 'worker',
				modules: true,
				bindings: { DO: { durableObjectNamespace: 'BLOCKING_DO' } },
				durableObjects: { BLOCKING_DO: { className: 'BlockingDO', scriptName: 'do-worker' } },
				compatibilityDate: '2025-04-23',
				script: `
					export default {
						async fetch(request, env) {
							const url = new URL(request.url);

							if (url.pathname === '/blockedOp') {
								const value = parseInt(url.searchParams.get('value'), 10);
								const lock = url.searchParams.get('lock');

								const id = env.BLOCKING_DO.idFromName('test');
								const stub = env.BLOCKING_DO.get(id);

								const result = await stub.blockedOp(value, lock);
								return new Response(JSON.stringify({ result }), {
									headers: { 'Content-Type': 'application/json' }
								});
							} else if (url.pathname === '/release') {
								const lock = url.searchParams.get('lock');

								const id = env.BLOCKING_DO.idFromName('test');
								const stub = env.BLOCKING_DO.get(id);

								await stub.release(lock);
								return new Response(JSON.stringify({ success: true }), {
									headers: { 'Content-Type': 'application/json' }
								});
							} else {
								return new Response('Not Found', { status: 404 });
							}
						}
					}
				`,
			},
		],
	});
});

afterEach(async () => {
	await mf.dispose();
});

it.skip('should demonstrate that blockedOp hangs without release', async () => {
	const ns = await mf.getDurableObjectNamespace('BLOCKING_DO');
	const id = ns.idFromName('test');
	const stub = ns.get(id);

	// Call blockedOp but don't await it
	const blockedPromise = stub.blockedOp(5, 'lock-1');

	// Race the blocked operation against a timeout
	// We expect the timeout to win since we never call release()
	const raced = await Promise.race([
		blockedPromise.then((result) => ({ type: 'resolved', result })),
		new Promise((resolve) => setTimeout(() => resolve({ type: 'timeout' }), 100)),
	]);

	// Never runs
	expect(raced).toEqual({ type: 'timeout' });
}, 5000);

it.skip('should demonstrate that release allows blockedOp to complete', async () => {
	const ns = await mf.getDurableObjectNamespace('BLOCKING_DO');
	const id = ns.idFromName('test');
	const stub = ns.get(id);

	const blockedPromise = stub.blockedOp(10, 'lock-2');

	// Release the lock after 50ms
	setTimeout(() => {
		stub.release('lock-2');
	}, 50);

	const result = await blockedPromise;

	// Never runs
	expect(result).toBe(12);
}, 5000);

it('should demonstrate that blockedOp hangs without release (via HTTP)', async () => {
	const worker = await mf.getWorker('worker');

	// Call blockedOp via HTTP
	const blockedPromise = worker.fetch('http://localhost/blockedOp?value=5&lock=http-lock-1').then((resp) => resp.json());

	// Race against timeout
	const raced = await Promise.race([
		blockedPromise.then((result) => ({ type: 'resolved', result })),
		new Promise((resolve) => setTimeout(() => resolve({ type: 'timeout' }), 100)),
	]);

	// Expected: timeout wins because we never call release
	expect(raced).toEqual({ type: 'timeout' });
}, 5000);

it('should demonstrate that release allows blockedOp to complete (via HTTP)', async () => {
	const worker = await mf.getWorker('worker');

	const blockedPromise = worker.fetch('http://localhost/blockedOp?value=10&lock=http-lock-2').then((resp) => resp.json());

	// Release the lock after 50ms
	setTimeout(() => {
		worker.fetch('http://localhost/release?lock=http-lock-2');
	}, 50);

	const data = await blockedPromise;

	expect(data).toEqual({ result: 12 });
}, 5000);

Please provide any relevant error logs

No response

Metadata

Metadata

Assignees

Labels

bugSomething that isn't workingminiflareRelating to MiniflarevitestRelating to the Workers Vitest integration

Type

Projects

Status

Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions