Fix stream serialization to resolve when user releases lock instead of waiting for stream to close - #661

Closed
TooTallNate wants to merge 2 commits into
mainfrom
12-20-fix_stream_serialization_to_resolve_when_user_releases_lock_instead_of_waiting_for_stream_to_close
Closed

Fix stream serialization to resolve when user releases lock instead of waiting for stream to close#661
TooTallNate wants to merge 2 commits into
mainfrom
12-20-fix_stream_serialization_to_resolve_when_user_releases_lock_instead_of_waiting_for_stream_to_close

Conversation

@TooTallNate

@TooTallNateTooTallNate commented Dec 20, 2025

Copy link
Copy Markdown
Member

Fix stream serialization to resolve when users release locks instead of waiting for streams to close, preventing Vercel functions from hanging.

What changed?

  • Implemented a polling mechanism to detect when stream locks are released
  • Added flushablePipe function that resolves in two scenarios:
    1. When the stream completes normally (close/error)
    2. When the user releases their lock AND all pending writes are flushed
  • Created a state tracking system to monitor pending operations and lock status

How to test?

  1. Create a workflow step that incrementally writes to a stream:

    constwriter=stream.getWriter();awaitwriter.write(data1);writer.releaseLock();// Step should complete here without waiting
  2. Verify the step completes immediately after lock release rather than hanging

  3. Run the new test cases that verify both lock release and normal stream closure behaviors

Why make this change?

This fixes an issue where Vercel functions would hang when users incrementally write to streams within steps. Previously, the system would wait for the stream to fully close before resolving, but many users follow a pattern where they write data and release the lock without explicitly closing the stream. This change allows steps to complete as soon as the user releases the lock and all pending writes are flushed, which is the expected behavior in most streaming scenarios.

@changeset-bot

changeset-botBot commented Dec 20, 2025

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 579162a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 12 packages
NameType
@workflow/corePatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/web-sharedPatch
workflowPatch
@workflow/astroPatch
@workflow/sveltekitPatch
@workflow/world-testingPatch
@workflow/nuxtPatch
@workflow/aiPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actionsBot commented Dec 20, 2025

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Starter🥇 Next.js (Turbopack)0.039s (+0.5%)1.013s (~)0.974s101.00x
💻 LocalNext.js (Turbopack)0.040s (-2.0%)1.017s (~)0.977s101.03x
💻 LocalNitro0.042s (-0.7%)1.007s (~)0.965s101.08x
🌐 RedisNext.js (Turbopack)0.042s (+0.7%)1.017s (~)0.975s101.08x
💻 LocalExpress0.043s (~)1.008s (~)0.965s101.11x
🌐 TursoNext.js (Turbopack)0.071s (-28.1% 🟢)1.014s (~)0.943s101.84x
🌐 MongoDBNext.js (Turbopack)0.107s (+44.9% 🔺)1.014s (~)0.907s102.76x
🐘 PostgresNext.js (Turbopack)0.128s (-21.4% 🟢)1.018s (~)0.890s103.31x
🐘 PostgresNitro0.343s (+13.9% 🔺)1.012s (~)0.669s108.87x
🐘 PostgresExpress0.369s (+16.2% 🔺)1.014s (~)0.645s109.54x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express0.552s (-9.8% 🟢)1.429s (-2.2%)0.877s101.00x
▲ VercelNext.js (Turbopack)0.556s (-12.4% 🟢)1.526s (-3.7%)0.970s101.01x
▲ VercelNitro0.569s (+8.1% 🔺)1.522s (-23.2% 🟢)0.953s101.03x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Starter🥇 Next.js (Turbopack)1.095s (+0.6%)2.009s (~)0.914s101.00x
💻 LocalNext.js (Turbopack)1.095s (~)2.012s (~)0.916s101.00x
🌐 RedisNext.js (Turbopack)1.097s (~)2.012s (~)0.915s101.00x
💻 LocalNitro1.110s (~)2.006s (~)0.896s101.01x
💻 LocalExpress1.112s (~)2.007s (~)0.895s101.02x
🌐 TursoNext.js (Turbopack)1.293s (-0.8%)2.012s (~)0.719s101.18x
🌐 MongoDBNext.js (Turbopack)1.309s (+0.8%)2.012s (~)0.703s101.20x
🐘 PostgresNitro2.196s (+2.5%)3.013s (~)0.817s102.01x
🐘 PostgresExpress2.207s (+1.9%)3.014s (~)0.807s102.02x
🐘 PostgresNext.js (Turbopack)2.458s (+27.9% 🔺)3.017s (+49.6% 🔺)0.559s102.25x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.619s (-2.6%)3.592s (-2.9%)0.973s101.00x
▲ VercelExpress2.626s (-1.6%)3.502s (-1.2%)0.876s101.00x
▲ VercelNitro2.669s (-2.4%)3.624s (-2.0%)0.955s101.02x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Starter🥇 Next.js (Turbopack)10.588s (~)11.013s (~)0.425s51.00x
💻 LocalNext.js (Turbopack)10.632s (~)11.016s (~)0.384s51.00x
🌐 RedisNext.js (Turbopack)10.678s (~)11.021s (~)0.342s51.01x
💻 LocalExpress10.781s (~)11.014s (~)0.234s51.02x
💻 LocalNitro10.787s (~)11.013s (~)0.226s51.02x
🌐 TursoNext.js (Turbopack)12.192s (~)13.020s (~)0.828s51.15x
🌐 MongoDBNext.js (Turbopack)12.197s (~)13.019s (~)0.822s51.15x
🐘 PostgresExpress16.081s (-21.2% 🟢)16.837s (-20.0% 🟢)0.756s51.52x
🐘 PostgresNitro18.950s (-6.9% 🟢)19.634s (-5.8% 🟢)0.683s51.79x
🐘 PostgresNext.js (Turbopack)20.287s (+33.3% 🔺)21.046s (+31.2% 🔺)0.760s51.92x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro21.714s (+0.7%)22.834s (+1.8%)1.120s51.00x
▲ VercelExpress21.984s (+1.6%)23.013s (+3.2%)1.029s51.01x
▲ VercelNext.js (Turbopack)22.185s (+1.8%)23.116s (+2.5%)0.932s51.02x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Starter🥇 Next.js (Turbopack)1.334s (~)2.007s (~)0.673s151.00x
🌐 RedisNext.js (Turbopack)1.365s (+0.6%)2.011s (~)0.646s151.02x
💻 LocalNext.js (Turbopack)1.373s (-1.6%)2.010s (~)0.636s151.03x
💻 LocalNitro1.408s (~)2.005s (~)0.597s151.06x
💻 LocalExpress1.408s (~)2.006s (~)0.598s151.06x
🐘 PostgresNext.js (Turbopack)1.901s (+3.1%)2.164s (+4.7%)0.263s141.43x
🐘 PostgresExpress1.939s (-18.7% 🟢)2.225s (-26.1% 🟢)0.287s141.45x
🌐 MongoDBNext.js (Turbopack)2.130s (~)3.011s (~)0.881s101.60x
🌐 TursoNext.js (Turbopack)2.198s (~)3.014s (~)0.815s101.65x
🐘 PostgresNitro2.351s (~)2.921s (-3.0%)0.569s111.76x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.844s (+4.8%)3.777s (-0.9%)0.933s81.00x
▲ VercelNext.js (Turbopack)2.994s (-1.9%)3.767s (-5.3% 🟢)0.774s81.05x
▲ VercelExpress3.634s (+32.8% 🔺)4.564s (+29.8% 🔺)0.930s71.28x

🔍 Observability: Nitro | Next.js (Turbopack) | Express

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)2.081s (-3.8%)2.911s (-6.6% 🟢)0.830s111.00x
💻 LocalExpress2.206s (~)3.160s (~)0.954s101.06x
💻 LocalNitro2.224s (~)3.169s (~)0.945s101.07x
🌐 StarterNext.js (Turbopack)2.448s (~)3.009s (~)0.560s101.18x
🌐 RedisNext.js (Turbopack)2.506s (+0.5%)3.012s (~)0.507s101.20x
🐘 PostgresExpress2.704s (-7.4% 🟢)3.016s (-6.8% 🟢)0.312s101.30x
🐘 PostgresNitro2.713s (+2.2%)3.012s (-0.5%)0.298s101.30x
🐘 PostgresNext.js (Turbopack)2.920s (+10.9% 🔺)3.411s (+12.5% 🔺)0.491s91.40x
🌐 MongoDBNext.js (Turbopack)4.685s (-1.8%)5.180s (~)0.495s62.25x
🌐 TursoNext.js (Turbopack)4.704s (+0.7%)5.178s (~)0.474s62.26x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.558s (+16.9% 🔺)4.236s (+15.0% 🔺)0.678s81.00x
▲ VercelNext.js (Turbopack)3.664s (+15.3% 🔺)4.434s (+16.2% 🔺)0.770s71.03x
▲ VercelNitro3.779s (+21.7% 🔺)4.309s (+12.4% 🔺)0.530s91.06x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Starter🥇 Next.js (Turbopack)1.350s (-1.6%)2.007s (~)0.656s151.00x
🌐 RedisNext.js (Turbopack)1.371s (-1.1%)2.009s (~)0.638s151.02x
💻 LocalNext.js (Turbopack)1.388s (-0.7%)2.013s (~)0.625s151.03x
💻 LocalExpress1.408s (~)2.006s (~)0.598s151.04x
💻 LocalNitro1.431s (~)2.005s (~)0.574s151.06x
🐘 PostgresExpress1.706s (-3.4%)2.009s (~)0.303s151.26x
🐘 PostgresNitro1.775s (-5.3% 🟢)2.011s (~)0.236s151.31x
🐘 PostgresNext.js (Turbopack)2.026s (+24.5% 🔺)2.231s (+10.8% 🔺)0.206s141.50x
🌐 MongoDBNext.js (Turbopack)2.176s (+2.7%)3.011s (~)0.835s101.61x
🌐 TursoNext.js (Turbopack)2.233s (+0.7%)3.015s (~)0.782s101.65x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.872s (+7.4% 🔺)3.757s (~)0.885s81.00x
▲ VercelExpress2.879s (+6.2% 🔺)4.136s (+17.9% 🔺)1.257s81.00x
▲ VercelNext.js (Turbopack)2.974s (+11.8% 🔺)4.393s (+20.0% 🔺)1.419s71.04x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)2.056s (-10.4% 🟢)2.916s (-10.1% 🟢)0.860s111.00x
💻 LocalExpress2.221s (-1.0%)3.152s (-1.1%)0.931s101.08x
💻 LocalNitro2.249s (-5.9% 🟢)3.178s (-4.6%)0.929s101.09x
🌐 StarterNext.js (Turbopack)2.458s (~)3.010s (~)0.552s101.20x
🐘 PostgresExpress2.502s (-8.1% 🟢)3.017s (~)0.515s101.22x
🌐 RedisNext.js (Turbopack)2.508s (+0.8%)3.012s (~)0.504s101.22x
🐘 PostgresNext.js (Turbopack)2.734s (+3.0%)3.028s (~)0.294s101.33x
🐘 PostgresNitro2.791s (+19.2% 🔺)3.038s (+0.9%)0.246s101.36x
🌐 TursoNext.js (Turbopack)4.693s (-1.6%)5.181s (~)0.488s62.28x
🌐 MongoDBNext.js (Turbopack)4.748s (+0.9%)5.179s (~)0.431s62.31x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.181s (+7.2% 🔺)3.741s (+1.6%)0.559s91.00x
▲ VercelExpress3.352s (+4.0%)3.953s (+3.9%)0.601s81.05x
▲ VercelNext.js (Turbopack)3.540s (+10.7% 🔺)4.341s (+11.9% 🔺)0.800s71.11x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🌐 Starter🥇 Next.js (Turbopack)0.125s (-3.2%)1.005s (~)0.000s (+Infinity% 🔺)1.010s (~)0.885s101.00x
💻 LocalNext.js (Turbopack)0.135s (-8.2% 🟢)1.003s (~)0.019s (+14.1% 🔺)1.029s (~)0.895s101.08x
🌐 RedisNext.js (Turbopack)0.141s (-1.1%)1.005s (~)0.000s (+Infinity% 🔺)1.013s (~)0.872s101.13x
💻 LocalNitro0.176s (~)0.992s (~)0.015s (-6.9% 🟢)1.021s (~)0.845s101.41x
💻 LocalExpress0.176s (~)0.992s (~)0.016s (~)1.022s (~)0.846s101.41x
🌐 TursoNext.js (Turbopack)0.461s (-5.9% 🟢)0.985s (+2.5%)0.000s (~)1.012s (~)0.551s103.69x
🌐 MongoDBNext.js (Turbopack)0.523s (+1.9%)0.925s (-1.1%)0.000s (+Infinity% 🔺)1.013s (~)0.490s104.18x
🐘 PostgresExpress1.353s (-41.6% 🟢)1.687s (-38.1% 🟢)0.000s (+200.0% 🔺)2.012s (-33.3% 🟢)0.659s1010.81x
🐘 PostgresNitro1.370s (-39.7% 🟢)1.670s (-39.7% 🟢)0.000s (-100.0% 🟢)2.012s (-33.2% 🟢)0.642s1010.95x
🐘 PostgresNext.js (Turbopack)1.608s (+35.8% 🔺)2.296s (+34.4% 🔺)0.000s (+Infinity% 🔺)2.418s (+32.9% 🔺)0.811s1012.85x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.631s (-11.4% 🟢)3.110s (-3.9%)0.657s (-8.1% 🟢)4.223s (-6.0% 🟢)1.592s101.00x
▲ VercelNitro2.770s (+4.7%)3.322s (+3.1%)0.567s (-15.4% 🟢)4.412s (+2.5%)1.643s101.05x
▲ VercelNext.js (Turbopack)2.788s (+6.0% 🔺)3.253s (+1.8%)0.832s (+45.0% 🔺)4.510s (+7.5% 🔺)1.722s101.06x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNext.js (Turbopack)8/8
🐘 PostgresExpress5/8
▲ VercelNitro4/8
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express💻 Local8/8
Next.js (Turbopack)🌐 Starter6/8
Nitro💻 Local8/8
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Starter: Community world (local development)
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)

📋 View full workflow run

@github-actions

github-actionsBot commented Dec 20, 2025

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production286011297
✅ 💻 Local Development26208270
✅ 📦 Local Production26208270
✅ 🐘 Local Postgres26208270
✅ 🪟 Windows270027
❌ 🌍 Community Worlds109110120
Total120811351254

❌ Failed Tests

🌍 Community Worlds (11 failed)

mongodb (1 failed):

  • webhookWorkflow

redis (1 failed):

  • webhookWorkflow

starter (8 failed):

  • addTenWorkflow
  • addTenWorkflow
  • retryAttemptCounterWorkflow
  • crossFileErrorWorkflow - stack traces work across imported modules
  • hookCleanupTestWorkflow - hook token reuse after workflow completion
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step

turso (1 failed):

  • webhookWorkflow

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro2601
✅ example2601
✅ express2601
✅ fastify2601
✅ hono2601
✅ nextjs-turbopack2601
✅ nextjs-webpack2601
✅ nitro2601
✅ nuxt2601
✅ sveltekit2601
✅ vite2601
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable2601
✅ express-stable2601
✅ fastify-stable2601
✅ hono-stable2601
✅ nextjs-turbopack-stable2700
✅ nextjs-webpack-stable2700
✅ nitro-stable2601
✅ nuxt-stable2601
✅ sveltekit-stable2601
✅ vite-stable2601
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable2601
✅ express-stable2601
✅ fastify-stable2601
✅ hono-stable2601
✅ nextjs-turbopack-stable2700
✅ nextjs-webpack-stable2700
✅ nitro-stable2601
✅ nuxt-stable2601
✅ sveltekit-stable2601
✅ vite-stable2601
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable2601
✅ express-stable2601
✅ fastify-stable2601
✅ hono-stable2601
✅ nextjs-turbopack-stable2700
✅ nextjs-webpack-stable2700
✅ nitro-stable2601
✅ nuxt-stable2601
✅ sveltekit-stable2601
✅ vite-stable2601
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack2700
❌ 🌍 Community Worlds
AppPassedFailedSkipped
✅ mongodb-dev300
❌ mongodb2610
✅ redis-dev300
❌ redis2610
✅ starter-dev300
❌ starter1980
✅ turso-dev300
❌ turso2610

📋 View full workflow run

@vercel

vercelBot commented Dec 20, 2025

Copy link
Copy Markdown
Contributor

@TooTallNateGraphite App

TooTallNate commented Dec 20, 2025

Copy link
Copy Markdown
MemberAuthor

Comment on lines +356 to +362
} catch (err) {
state.streamEnded = true;
if (!state.doneResolved) {
state.doneResolved = true;
state.reject(err);
}
throw err;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stream locks are not released when an error occurs in the pipe. The error is thrown without releasing the locks acquired at the start of the function.

View Details
📝 Patch Details
diff --git a/packages/core/src/serialization.ts b/packages/core/src/serialization.ts
index c8ebc73..23e2ae6 100644
--- a/packages/core/src/serialization.ts+++ b/packages/core/src/serialization.ts@@ -359,6 +359,8 @@ async function flushablePipe(
state.doneResolved = true;
state.reject(err);
}
+ reader.releaseLock();+ writer.releaseLock();
throw err;
}
}

Analysis

Stream locks not released when error occurs in flushablePipe

What fails: The flushablePipe() function in packages/core/src/serialization.ts does not release the reader and writer locks when an error is thrown in the catch block (lines 356-362), leaving the streams in a permanently locked state.

How to reproduce: Create a test scenario where an error occurs during pipe operation:

// Create a readable streamconstsource=newReadableStream({start(controller){controller.enqueue('chunk1');controller.enqueue('chunk2');controller.close();}});// Create a writable stream that errors on second writeletwriteCount=0;constsink=newWritableStream({asyncwrite(chunk){writeCount++;if(writeCount===2){thrownewError('Write failed!');}}});// Call flushablePipe with error stateconststate={pendingOps: 0,doneResolved: false,streamEnded: false,resolve: ()=>{},reject: ()=>{}};try{awaitflushablePipe(source,sink,state);}catch(err){// Error caught}// Attempt to acquire new reader/writersource.getReader();// Throws: "InvalidStateError: ReadableStream is locked"sink.getWriter();// Throws: "InvalidStateError: WritableStream is locked"

Result: Streams remain locked (source.locked === true, sink.locked === true), preventing new readers/writers from being obtained. Cannot acquire new reader: "InvalidStateError: ReadableStream is locked". Cannot acquire new writer: "InvalidStateError: WritableStream is locked".

Expected behavior: The locks should be released before throwing the error, as they are in normal code paths (lines 326, 339, 346). Per WHATWG Streams API specification, a lock must be explicitly released, and a locked stream prevents other readers/writers from being obtained. Leaving locks unreleased creates a resource leak that prevents stream reuse.

Root cause: The catch block at lines 356-362 sets error state and throws without calling reader.releaseLock() and writer.releaseLock(), unlike all other exit paths from the function which properly release locks.

Fix applied: Added reader.releaseLock() and writer.releaseLock() before the throw err statement in the catch block to match the cleanup pattern used in normal code paths.

Comment on lines +329 to +338
if (readResult.done) {
// Source stream completed - close sink and resolve
state.streamEnded = true;
await writer.close();
// Resolve done promise if not already resolved
if (!state.doneResolved) {
state.doneResolved = true;
state.resolve();
}
return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stream locks are not released when the source stream ends. The flushablePipe function acquires locks on the reader and writer but doesn't release them before returning when the readable stream completes.

View Details
📝 Patch Details
diff --git a/packages/core/src/serialization.ts b/packages/core/src/serialization.ts
index c8ebc73..57e4750 100644
--- a/packages/core/src/serialization.ts+++ b/packages/core/src/serialization.ts@@ -335,6 +335,8 @@ async function flushablePipe(
state.doneResolved = true;
state.resolve();
}
+ reader.releaseLock();+ writer.releaseLock();
return;
}
@@ -359,6 +361,8 @@ async function flushablePipe(
state.doneResolved = true;
state.reject(err);
}
+ reader.releaseLock();+ writer.releaseLock();
throw err;
}
}

Analysis

Stream locks not released in flushablePipe() when source completes or errors

What fails: The flushablePipe() function in packages/core/src/serialization.ts (lines 333-341 and 365-371) acquires locks on ReadableStream and WritableStream but fails to release them before returning when the stream completes or when an error occurs.

How to reproduce:

// When readResult.done is true (source stream completed):constreadResult=awaitreader.read();if(readResult.done){state.streamEnded=true;awaitwriter.close();// ... resolve promise ...return;// ❌ Returns without releasing locks}// Or when error occurs:}catch(err){state.streamEnded=true;// ... reject promise ...throwerr;// ❌ Throws without releasing locks}

Result: Unreleased locks prevent any other code from acquiring new readers or writers on these streams. Subsequent attempts to call getReader() or getWriter() will throw TypeError: ReadableStreamDefaultReader constructor can only accept readable streams that are not yet locked to a reader. The stream becomes permanently inaccessible.

Expected behavior: According to MDN Web JavaScript resource management, unreleased locks cause the stream to remain locked indefinitely and "does not permit another reader to consume it." The locks must be explicitly released via reader.releaseLock() and writer.releaseLock() before the function exits.

Fix: Added reader.releaseLock() and writer.releaseLock() calls before all return/throw statements in the flushablePipe function:

  • Before returning when readResult.done (normal stream completion)
  • Before throwing in the catch block (error case)

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements a polling mechanism to detect when stream locks are released, allowing workflow steps to complete without waiting for streams to fully close. This fixes an issue where Vercel functions would hang when users write to streams and release locks without explicitly closing them.

Key Changes:

  • Added flushablePipe function that resolves when either the stream completes or when the user releases their lock with all pending writes flushed
  • Implemented polling functions (pollWritableLock and pollReadableLock) that check stream lock state every 100ms
  • Created state tracking system (FlushableStreamState) to monitor pending operations and resolution status

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 15 comments.

FileDescription
packages/core/src/serialization.tsImplements the core flushable pipe mechanism with polling-based lock detection and state tracking for both readable and writable streams
packages/core/src/serialization.test.tsAdds tests verifying the new behavior for both lock release and natural stream closure scenarios
.changeset/stream-lock-polling.mdDocuments the patch-level change for the @workflow/core package

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +1174 to +1176
flushablePipe(readable, transform.writable, state).catch(() => {
// Errors are handled via state.reject
});

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The flushablePipe function throws errors after calling state.reject() (line 405), but all call sites catch and ignore these thrown errors with empty catch blocks (e.g., line 976, 992, 1016). This pattern is confusing because the error is both rejected through the state AND thrown. Since the error is already propagated via state.reject() which rejects the 'done' promise, the re-throw serves no purpose and the empty catch blocks make this unclear. Consider either removing the throw from flushablePipe or documenting why the error needs to be both rejected and thrown.

Copilot uses AI. Check for mistakes.
Comment on lines +399 to +406
} catch (err) {
state.streamEnded = true;
if (!state.doneResolved) {
state.doneResolved = true;
state.reject(err);
}
throw err;
}

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error handling in the flushablePipe function could potentially leak resources. When an exception occurs in the catch block at line 399, the reader and writer locks are not released before the error is re-thrown. This could leave the streams in a locked state. Consider releasing the locks in the catch block before re-throwing.

Copilot uses AI. Check for mistakes.
Comment on lines +229 to +261
/**
* Checks if a WritableStream is unlocked (user released lock) vs closed.
* When a stream is closed, .locked is false but getWriter() throws.
* We only want to resolve via polling when the stream is unlocked, not closed.
* If closed, the pump will handle resolution via the stream ending naturally.
*/
function isWritableUnlockedNotClosed(writable: WritableStream): boolean {
if (writable.locked) return false;

try {
// Try to acquire writer - if successful, stream is unlocked (not closed)
const writer = writable.getWriter();
writer.releaseLock();
return true;
} catch {
// getWriter() throws if stream is closed/errored - let pump handle it
return false;
}
}

/**
* Checks if a ReadableStream is unlocked (user released lock) vs closed.
*/
function isReadableUnlockedNotClosed(readable: ReadableStream): boolean {
if (readable.locked) return false;

try {
// Try to acquire reader - if successful, stream is unlocked (not closed)
const reader = readable.getReader();
reader.releaseLock();
return true;
} catch {
// getReader() throws if stream is closed/errored - let pump handle it

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The polling functions acquire and release stream locks every 100ms to check if a stream is unlocked vs closed. This repeated lock acquisition could have performance implications and might interfere with stream operations. Consider using a different approach to detect stream closure, such as checking for specific error messages or using a flag that's set when the stream closes, to avoid repeatedly acquiring locks during polling.

Suggested change
/**
*ChecksifaWritableStreamisunlocked(userreleasedlock)vsclosed.
*Whenastreamisclosed,.lockedisfalsebutgetWriter()throws.
*Weonlywanttoresolveviapollingwhenthestreamisunlocked,notclosed.
*Ifclosed,thepumpwillhandleresolutionviathestreamendingnaturally.
*/
functionisWritableUnlockedNotClosed(writable: WritableStream): boolean{
if(writable.locked)returnfalse;
try{
// Try to acquire writer - if successful, stream is unlocked (not closed)
constwriter=writable.getWriter();
writer.releaseLock();
returntrue;
}catch{
// getWriter() throws if stream is closed/errored - let pump handle it
returnfalse;
}
}
/**
*ChecksifaReadableStreamisunlocked(userreleasedlock)vsclosed.
*/
functionisReadableUnlockedNotClosed(readable: ReadableStream): boolean{
if(readable.locked)returnfalse;
try{
// Try to acquire reader - if successful, stream is unlocked (not closed)
constreader=readable.getReader();
reader.releaseLock();
returntrue;
}catch{
// getReader() throws if stream is closed/errored - let pump handle it
/**
*Cacheofper-stream"unlocked but not closed"statusforWritableStreams.
*Thisavoidsrepeatedlyacquiringandreleasinglocksduringpolling;we
*probethestreamatmostonceperinstancewhenittransitionstounlocked.
*/
constwritableUnlockedNotClosedCache=newWeakMap<WritableStream,boolean>();
/**
*ChecksifaWritableStreamisunlocked(userreleasedlock)vsclosed.
*Whenastreamisclosed,.lockedisfalsebutgetWriter()throws.
*Weonlywanttoresolveviapollingwhenthestreamisunlocked,notclosed.
*Ifclosed,thepumpwillhandleresolutionviathestreamendingnaturally.
*
*Toavoidrepeatedlockacquisitionduringpolling,wecachetheresultof
*theunlocked-vs-closedprobeperstream.Oncedetermined,thestatus is
*reusedonsubsequentcallsforthesamestream.
*/
functionisWritableUnlockedNotClosed(writable: WritableStream): boolean{
if(writable.locked)returnfalse;
constcached=writableUnlockedNotClosedCache.get(writable);
if(cached!==undefined){
returncached;
}
try{
// Try to acquire writer - if successful, stream is unlocked (not closed)
constwriter=writable.getWriter();
writer.releaseLock();
writableUnlockedNotClosedCache.set(writable,true);
returntrue;
}catch{
// getWriter() throws if stream is closed/errored - let pump handle it
writableUnlockedNotClosedCache.set(writable,false);
returnfalse;
}
}
/**
*Cacheofper-stream"unlocked but not closed"statusforReadableStreams.
*Likethewritablecache,thispreventsrepeatedreaderacquisitionswhile
*pollingforlockrelease.
*/
constreadableUnlockedNotClosedCache=newWeakMap<ReadableStream,boolean>();
/**
*ChecksifaReadableStreamisunlocked(userreleasedlock)vsclosed.
*Usesaper-streamcachetoavoidrepeatedlyacquiringandreleasinglocks
*duringpolling.
*/
functionisReadableUnlockedNotClosed(readable: ReadableStream): boolean{
if(readable.locked)returnfalse;
constcached=readableUnlockedNotClosedCache.get(readable);
if(cached!==undefined){
returncached;
}
try{
// Try to acquire reader - if successful, stream is unlocked (not closed)
constreader=readable.getReader();
reader.releaseLock();
readableUnlockedNotClosedCache.set(readable,true);
returntrue;
}catch{
// getReader() throws if stream is closed/errored - let pump handle it
readableUnlockedNotClosedCache.set(readable,false);

Copilot uses AI. Check for mistakes.
Comment on lines +1179 to +1186
} catch (err) {
state.streamEnded = true;
if (!state.doneResolved) {
state.doneResolved = true;
state.reject(err as Error);
}
}
})();

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error handling in the test's pipe implementation (line 1179-1185) doesn't release the reader and writer locks before exiting, similar to the issue in the actual flushablePipe implementation. While this mirrors the production code, it would be better to release the locks in the catch block to properly clean up resources. Consider updating the test to match the corrected implementation once the production code is fixed.

Copilot uses AI. Check for mistakes.
Comment on lines +1016 to +1018
flushablePipe(serialize.readable, serverWritable, state).catch(() => {
// Errors are handled via state.reject
});

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The flushablePipe function throws errors after calling state.reject() (line 405), but all call sites catch and ignore these thrown errors with empty catch blocks (e.g., line 976, 992, 1016). This pattern is confusing because the error is both rejected through the state AND thrown. Since the error is already propagated via state.reject() which rejects the 'done' promise, the re-throw serves no purpose and the empty catch blocks make this unclear. Consider either removing the throw from flushablePipe or documenting why the error needs to be both rejected and thrown.

Suggested change
flushablePipe(serialize.readable,serverWritable,state).catch(()=>{
// Errors are handled via state.reject
});
flushablePipe(serialize.readable,serverWritable,state).catch(
(error)=>{
// flushablePipe already calls state.reject(error), which rejects the
// `done` promise added to `ops`. We catch and intentionally ignore
// this rejection here only to avoid an unhandled promise rejection;
// all real error handling flows through state.done.
}
);

Copilot uses AI. Check for mistakes.
Comment on lines +1001 to +1229
describe('flushable stream behavior', () => {
const POLL_INTERVAL = 100; // Match the actual implementation
const STABLE_POLL_COUNT = 2; // Match the actual implementation

it('done promise should resolve when writable stream lock is released (polling)', async () => {
// Test the pattern: user writes, releases lock, polling detects it, done resolves
const chunks: string[] = [];
let streamClosed = false;

// Create a simple mock for the sink
const mockSink = new WritableStream<string>({
write(chunk) {
chunks.push(chunk);
},
close() {
streamClosed = true;
},
});

// Create a TransformStream like we do in getStepRevivers
const { readable, writable } = new TransformStream<string, string>();

// Track flushable state - this mirrors the actual implementation
const state = {
pendingOps: 0, // Only counts writes to server
doneResolved: false,
streamEnded: false,
resolve: () => {},
reject: (_err: Error) => {},
};

const done = new Promise<void>((res, rej) => {
state.resolve = res;
state.reject = rej;
});

// Start piping in background (mirrors flushablePipe implementation)
(async () => {
const reader = readable.getReader();
const writer = mockSink.getWriter();
try {
while (!state.streamEnded) {
const result = await reader.read();

if (result.done) {
state.streamEnded = true;
await writer.close();
if (!state.doneResolved) {
state.doneResolved = true;
state.resolve();
}
return;
}

// Only writes count as pending ops
state.pendingOps++;
await writer.write(result.value);
state.pendingOps--;

if (state.streamEnded) {
reader.releaseLock();
writer.releaseLock();
return;
}
}
} catch (err) {
state.streamEnded = true;
if (!state.doneResolved) {
state.doneResolved = true;
state.reject(err as Error);
}
}
})();

// Start polling (mirrors pollWritableLock implementation)
let stableCount = 0;
const intervalId = setInterval(() => {
if (state.doneResolved || state.streamEnded) {
clearInterval(intervalId);
return;
}

// Check if lock is released by checking .locked property
if (!writable.locked && state.pendingOps === 0) {
stableCount++;
if (stableCount >= STABLE_POLL_COUNT) {
state.doneResolved = true;
state.resolve();
clearInterval(intervalId);
}
} else {
stableCount = 0;
}
}, POLL_INTERVAL);

// Simulate user interaction - write and release lock
const userWriter = writable.getWriter();
await userWriter.write('chunk1');
await userWriter.write('chunk2');

// Release lock without closing stream
userWriter.releaseLock();

// Wait for pipe to process + polling intervals (need STABLE_POLL_COUNT consecutive polls)
await new Promise((r) => setTimeout(r, 250));

// The done promise should resolve
await expect(
Promise.race([
done,
new Promise((_, r) => setTimeout(() => r(new Error('timeout')), 400)),
])
).resolves.toBeUndefined();

// Chunks should have been written
expect(chunks).toContain('chunk1');
expect(chunks).toContain('chunk2');

// Stream should NOT be closed (user only released lock)
expect(streamClosed).toBe(false);
});

it('done promise should resolve when writable stream closes naturally', async () => {
const chunks: string[] = [];
let streamClosed = false;

const mockSink = new WritableStream<string>({
write(chunk) {
chunks.push(chunk);
},
close() {
streamClosed = true;
},
});

const { readable, writable } = new TransformStream<string, string>();

const state = {
pendingOps: 0,
doneResolved: false,
streamEnded: false,
resolve: () => {},
reject: (_err: Error) => {},
};

const done = new Promise<void>((res, rej) => {
state.resolve = res;
state.reject = rej;
});

// Start piping in background
(async () => {
const reader = readable.getReader();
const writer = mockSink.getWriter();
try {
while (!state.streamEnded) {
const result = await reader.read();

if (result.done) {
state.streamEnded = true;
await writer.close();
if (!state.doneResolved) {
state.doneResolved = true;
state.resolve();
}
return;
}

state.pendingOps++;
await writer.write(result.value);
state.pendingOps--;

if (state.streamEnded) {
reader.releaseLock();
writer.releaseLock();
return;
}
}
} catch (err) {
state.streamEnded = true;
if (!state.doneResolved) {
state.doneResolved = true;
state.reject(err as Error);
}
}
})();

// Start polling (won't trigger since stream will close first)
let stableCount2 = 0;
const intervalId = setInterval(() => {
if (state.doneResolved || state.streamEnded) {
clearInterval(intervalId);
return;
}
if (!writable.locked && state.pendingOps === 0) {
stableCount2++;
if (stableCount2 >= STABLE_POLL_COUNT) {
state.doneResolved = true;
state.resolve();
clearInterval(intervalId);
}
} else {
stableCount2 = 0;
}
}, POLL_INTERVAL);

// User writes and then closes the stream
const userWriter = writable.getWriter();
await userWriter.write('data');
await userWriter.close();

// Wait a tick for the pipe to process
await new Promise((r) => setTimeout(r, 50));

// The done promise should resolve
await expect(
Promise.race([
done,
new Promise((_, r) => setTimeout(() => r(new Error('timeout')), 200)),
])
).resolves.toBeUndefined();

// Chunks should have been written
expect(chunks).toContain('data');

// Stream should be closed (user closed it)
expect(streamClosed).toBe(true);
});
});

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new flushable stream behavior tests only cover the happy paths (lock release and normal closure). Consider adding test cases for error scenarios such as: stream errors during write operations, reader errors, and what happens when the stream is aborted. This would help ensure the error handling in flushablePipe and the polling mechanisms work correctly under failure conditions.

Copilot uses AI. Check for mistakes.
Comment on lines +1158 to +1160
flushablePipe(readable, writable, state).catch(() => {
// Errors are handled via state.reject
});

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The flushablePipe function throws errors after calling state.reject() (line 405), but all call sites catch and ignore these thrown errors with empty catch blocks (e.g., line 976, 992, 1016). This pattern is confusing because the error is both rejected through the state AND thrown. Since the error is already propagated via state.reject() which rejects the 'done' promise, the re-throw serves no purpose and the empty catch blocks make this unclear. Consider either removing the throw from flushablePipe or documenting why the error needs to be both rejected and thrown.

Copilot uses AI. Check for mistakes.
Comment on lines +192 to +196
* - `doneResolved`: The `done` promise has been resolved (step can complete)
* - `streamEnded`: The underlying stream has actually closed/errored
*
* The pump continues running even after `doneResolved=true` to handle
* any future writes if the user acquires a new lock.

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment states that "The pump continues running even after doneResolved=true to handle any future writes if the user acquires a new lock." However, this design could be problematic. Once the done promise resolves (pushed to ops), the workflow step will complete. If the user then acquires a new lock and writes more data after the step has completed, it could lead to unexpected behavior where data is being written to a stream associated with an already-completed step. Consider whether this is the intended behavior or if the stream should be closed/locked after the done promise resolves.

Suggested change
*-`doneResolved`: The`done`promisehasbeenresolved(stepcancomplete)
*-`streamEnded`: Theunderlyingstreamhasactuallyclosed/errored
*
*Thepumpcontinuesrunningevenafter`doneResolved=true`tohandle
* any futurewritesiftheuseracquiresanewlock.
*-`doneResolved`: The`done`promisehasbeenresolved(stepcancomplete),
*andnofurtherwritesshouldbeacceptedforthisstream.
*-`streamEnded`: Theunderlyingstreamhasactuallyclosed/errored.
*
*Once`doneResolved`becomes`true`,thepumpshouldonlydrainany
*already-pendingoperationsandthenstop;itmustnothandlenewwrites
*fromsubsequentlyacquiredlocks.

Copilot uses AI. Check for mistakes.
Comment on lines +1066 to +1073
} catch (err) {
state.streamEnded = true;
if (!state.doneResolved) {
state.doneResolved = true;
state.reject(err as Error);
}
}
})();

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error handling in the test's pipe implementation (line 1066-1072) doesn't release the reader and writer locks before exiting, similar to the issue in the actual flushablePipe implementation. While this mirrors the production code, it would be better to release the locks in the catch block to properly clean up resources. Consider updating the test to match the corrected implementation once the production code is fixed.

Copilot uses AI. Check for mistakes.
Comment on lines +1189 to +1203
let stableCount2 = 0;
const intervalId = setInterval(() => {
if (state.doneResolved || state.streamEnded) {
clearInterval(intervalId);
return;
}
if (!writable.locked && state.pendingOps === 0) {
stableCount2++;
if (stableCount2 >= STABLE_POLL_COUNT) {
state.doneResolved = true;
state.resolve();
clearInterval(intervalId);
}
} else {
stableCount2 = 0;

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The variable name stableCount2 is unclear and suggests copy-paste from the first test. Consider renaming to stableCount for consistency, as the numbering doesn't add clarity and makes the code harder to read.

Suggested change
letstableCount2=0;
constintervalId=setInterval(()=>{
if(state.doneResolved||state.streamEnded){
clearInterval(intervalId);
return;
}
if(!writable.locked&&state.pendingOps===0){
stableCount2++;
if(stableCount2>=STABLE_POLL_COUNT){
state.doneResolved=true;
state.resolve();
clearInterval(intervalId);
}
}else{
stableCount2=0;
letstableCount=0;
constintervalId=setInterval(()=>{
if(state.doneResolved||state.streamEnded){
clearInterval(intervalId);
return;
}
if(!writable.locked&&state.pendingOps===0){
stableCount++;
if(stableCount>=STABLE_POLL_COUNT){
state.doneResolved=true;
state.resolve();
clearInterval(intervalId);
}
}else{
stableCount=0;

Copilot uses AI. Check for mistakes.

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before I review - there's a ton of AI comments on this PR and I want to give you a chance to resolve those, so I'm subscribing and re-reviewing after the next commit/message

Base automatically changed from 12-22-fix_race_conditions_in_webhook_response_handling to mainDecember 22, 2025 18:41
@TooTallNate

Copy link
Copy Markdown
MemberAuthor

@copilot Please evaluate the code review suggestions in this PR and apply them if you think they are relevent.

CopilotAI commented Dec 22, 2025

Copy link
Copy Markdown
Contributor

@TooTallNate I've opened a new pull request, #677, to work on those changes. Once the pull request is ready, I'll request review from you.

…f waiting for stream to close
This prevents Vercel functions from hanging when users incrementally write to streams within steps (e.g., `await writer.write(data); writer.releaseLock()`). Uses a polling approach to detect when the stream lock is released and all pending writes are flushed.
Comment on lines +271 to +316
function pollWritableLock(
writable: WritableStream,
state: FlushableStreamState
): void {
const intervalId = setInterval(() => {
// Stop polling if already resolved or stream ended
if (state.doneResolved || state.streamEnded) {
clearInterval(intervalId);
return;
}

// Check if lock is released (not closed) and no pending ops
if (isWritableUnlockedNotClosed(writable) && state.pendingOps === 0) {
state.doneResolved = true;
state.resolve();
clearInterval(intervalId);
}
}, LOCK_POLL_INTERVAL_MS);
}

/**
* Polls a ReadableStream to check if the user has released their lock.
* Resolves the done promise when lock is released and no pending ops remain.
*
* Note: Only resolves if stream is unlocked but NOT closed. If the user closes
* the stream, the pump will handle resolution via the stream ending naturally.
*/
function pollReadableLock(
readable: ReadableStream,
state: FlushableStreamState
): void {
const intervalId = setInterval(() => {
// Stop polling if already resolved or stream ended
if (state.doneResolved || state.streamEnded) {
clearInterval(intervalId);
return;
}

// Check if lock is released (not closed) and no pending ops
if (isReadableUnlockedNotClosed(readable) && state.pendingOps === 0) {
state.doneResolved = true;
state.resolve();
clearInterval(intervalId);
}
}, LOCK_POLL_INTERVAL_MS);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The polling functions don't implement the "multiple consecutive polls" behavior mentioned in the docstring, creating a potential race condition where the done promise resolves before the pump finishes writing queued data.

View Details
📝 Patch Details
diff --git a/packages/core/src/serialization.ts b/packages/core/src/serialization.ts
index 69c391b..376afe4 100644
--- a/packages/core/src/serialization.ts+++ b/packages/core/src/serialization.ts@@ -182,6 +182,9 @@ export class WorkflowServerWritableStream extends WritableStream<Uint8Array> {
/** Polling interval for checking stream lock state (in milliseconds) */
const LOCK_POLL_INTERVAL_MS = 100;
+/** Number of consecutive stable polls before resolving done promise */+const STABLE_POLL_COUNT = 2;+
/**
* State tracker for flushable stream operations.
* Resolves when either:
@@ -272,6 +275,7 @@ function pollWritableLock(
writable: WritableStream,
state: FlushableStreamState
): void {
+ let stableCount = 0;
const intervalId = setInterval(() => {
// Stop polling if already resolved or stream ended
if (state.doneResolved || state.streamEnded) {
@@ -281,16 +285,22 @@ function pollWritableLock(
// Check if lock is released (not closed) and no pending ops
if (isWritableUnlockedNotClosed(writable) && state.pendingOps === 0) {
- state.doneResolved = true;- state.resolve();- clearInterval(intervalId);+ stableCount++;+ if (stableCount >= STABLE_POLL_COUNT) {+ state.doneResolved = true;+ state.resolve();+ clearInterval(intervalId);+ }+ } else {+ stableCount = 0;
}
}, LOCK_POLL_INTERVAL_MS);
}
/**
* Polls a ReadableStream to check if the user has released their lock.
- * Resolves the done promise when lock is released and no pending ops remain.+ * Resolves the done promise when lock is released and no pending ops remain+ * for multiple consecutive polls (to avoid race conditions with the pump).
*
* Note: Only resolves if stream is unlocked but NOT closed. If the user closes
* the stream, the pump will handle resolution via the stream ending naturally.
@@ -299,6 +309,7 @@ function pollReadableLock(
readable: ReadableStream,
state: FlushableStreamState
): void {
+ let stableCount = 0;
const intervalId = setInterval(() => {
// Stop polling if already resolved or stream ended
if (state.doneResolved || state.streamEnded) {
@@ -308,9 +319,14 @@ function pollReadableLock(
// Check if lock is released (not closed) and no pending ops
if (isReadableUnlockedNotClosed(readable) && state.pendingOps === 0) {
- state.doneResolved = true;- state.resolve();- clearInterval(intervalId);+ stableCount++;+ if (stableCount >= STABLE_POLL_COUNT) {+ state.doneResolved = true;+ state.resolve();+ clearInterval(intervalId);+ }+ } else {+ stableCount = 0;
}
}, LOCK_POLL_INTERVAL_MS);
}

Analysis

Race condition in polling functions: done promise resolves before pump finishes flushing

What fails: The pollWritableLock() and pollReadableLock() functions in packages/core/src/serialization.ts resolve the done promise immediately when the stream lock is released and pendingOps === 0, without waiting for multiple consecutive stable polls. This creates a race condition where the done promise can resolve while the pump is still reading/writing data.

How to reproduce:

  1. A stream is being piped asynchronously via flushablePipe()
  2. User releases the lock on the stream (but doesn't close it)
  3. Pump is momentarily between reads (at await reader.read()) with pendingOps === 0
  4. Polling fires and sees: lock released + pendingOps === 0 → resolves done promise immediately
  5. Runtime awaits Promise.all(ops) which includes the done promise and proceeds
  6. If pump reads more data and tries to write, the step is already marked complete
  7. This violates the contract that all writes must be flushed before the step completes

Expected behavior: Per the docstring at lines 265-266 and 293-294, polling should only resolve after the condition is stable for "multiple consecutive polls (to avoid race conditions with the pump)." The test file at lines 1002-1003 and 1084-1090 confirms this behavior by using STABLE_POLL_COUNT = 2 - only resolving after the unlock condition is seen 2 consecutive times.

Root cause: The actual implementation was missing the stability counter logic that the docstring promised and that the test expected. Added STABLE_POLL_COUNT constant and stability tracking to both polling functions.

Evidence:

  • Docstring promises: "for multiple consecutive polls (to avoid race conditions with the pump)"
  • Test implementation (lines 1084-1090): Implements stability counter that resets when condition changes
  • Test comment (line 1001): "mirrors the actual implementation"
  • Git history: Commit e8c3800 added polling with docstring but without stability logic

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@TooTallNate@VaguelySerious
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all \u003cpre\u003e\u003ccode\u003e blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks"); } } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); } })(); (function(){ try { var __m = "github.com"; var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Fix stream serialization to resolve when user releases lock instead of waiting for stream to close - #661

Closed
TooTallNate wants to merge 2 commits into
mainfrom
12-20-fix_stream_serialization_to_resolve_when_user_releases_lock_instead_of_waiting_for_stream_to_close
Closed

Fix stream serialization to resolve when user releases lock instead of waiting for stream to close#661
TooTallNate wants to merge 2 commits into
mainfrom
12-20-fix_stream_serialization_to_resolve_when_user_releases_lock_instead_of_waiting_for_stream_to_close

Conversation

@TooTallNate

@TooTallNateTooTallNate commented Dec 20, 2025

Copy link
Copy Markdown
Member

Fix stream serialization to resolve when users release locks instead of waiting for streams to close, preventing Vercel functions from hanging.

What changed?

  • Implemented a polling mechanism to detect when stream locks are released
  • Added flushablePipe function that resolves in two scenarios:
    1. When the stream completes normally (close/error)
    2. When the user releases their lock AND all pending writes are flushed
  • Created a state tracking system to monitor pending operations and lock status

How to test?

  1. Create a workflow step that incrementally writes to a stream:

    constwriter=stream.getWriter();awaitwriter.write(data1);writer.releaseLock();// Step should complete here without waiting
  2. Verify the step completes immediately after lock release rather than hanging

  3. Run the new test cases that verify both lock release and normal stream closure behaviors

Why make this change?

This fixes an issue where Vercel functions would hang when users incrementally write to streams within steps. Previously, the system would wait for the stream to fully close before resolving, but many users follow a pattern where they write data and release the lock without explicitly closing the stream. This change allows steps to complete as soon as the user releases the lock and all pending writes are flushed, which is the expected behavior in most streaming scenarios.

@changeset-bot

changeset-botBot commented Dec 20, 2025

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 579162a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 12 packages
NameType
@workflow/corePatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/web-sharedPatch
workflowPatch
@workflow/astroPatch
@workflow/sveltekitPatch
@workflow/world-testingPatch
@workflow/nuxtPatch
@workflow/aiPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actionsBot commented Dec 20, 2025

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Starter🥇 Next.js (Turbopack)0.039s (+0.5%)1.013s (~)0.974s101.00x
💻 LocalNext.js (Turbopack)0.040s (-2.0%)1.017s (~)0.977s101.03x
💻 LocalNitro0.042s (-0.7%)1.007s (~)0.965s101.08x
🌐 RedisNext.js (Turbopack)0.042s (+0.7%)1.017s (~)0.975s101.08x
💻 LocalExpress0.043s (~)1.008s (~)0.965s101.11x
🌐 TursoNext.js (Turbopack)0.071s (-28.1% 🟢)1.014s (~)0.943s101.84x
🌐 MongoDBNext.js (Turbopack)0.107s (+44.9% 🔺)1.014s (~)0.907s102.76x
🐘 PostgresNext.js (Turbopack)0.128s (-21.4% 🟢)1.018s (~)0.890s103.31x
🐘 PostgresNitro0.343s (+13.9% 🔺)1.012s (~)0.669s108.87x
🐘 PostgresExpress0.369s (+16.2% 🔺)1.014s (~)0.645s109.54x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express0.552s (-9.8% 🟢)1.429s (-2.2%)0.877s101.00x
▲ VercelNext.js (Turbopack)0.556s (-12.4% 🟢)1.526s (-3.7%)0.970s101.01x
▲ VercelNitro0.569s (+8.1% 🔺)1.522s (-23.2% 🟢)0.953s101.03x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Starter🥇 Next.js (Turbopack)1.095s (+0.6%)2.009s (~)0.914s101.00x
💻 LocalNext.js (Turbopack)1.095s (~)2.012s (~)0.916s101.00x
🌐 RedisNext.js (Turbopack)1.097s (~)2.012s (~)0.915s101.00x
💻 LocalNitro1.110s (~)2.006s (~)0.896s101.01x
💻 LocalExpress1.112s (~)2.007s (~)0.895s101.02x
🌐 TursoNext.js (Turbopack)1.293s (-0.8%)2.012s (~)0.719s101.18x
🌐 MongoDBNext.js (Turbopack)1.309s (+0.8%)2.012s (~)0.703s101.20x
🐘 PostgresNitro2.196s (+2.5%)3.013s (~)0.817s102.01x
🐘 PostgresExpress2.207s (+1.9%)3.014s (~)0.807s102.02x
🐘 PostgresNext.js (Turbopack)2.458s (+27.9% 🔺)3.017s (+49.6% 🔺)0.559s102.25x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.619s (-2.6%)3.592s (-2.9%)0.973s101.00x
▲ VercelExpress2.626s (-1.6%)3.502s (-1.2%)0.876s101.00x
▲ VercelNitro2.669s (-2.4%)3.624s (-2.0%)0.955s101.02x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Starter🥇 Next.js (Turbopack)10.588s (~)11.013s (~)0.425s51.00x
💻 LocalNext.js (Turbopack)10.632s (~)11.016s (~)0.384s51.00x
🌐 RedisNext.js (Turbopack)10.678s (~)11.021s (~)0.342s51.01x
💻 LocalExpress10.781s (~)11.014s (~)0.234s51.02x
💻 LocalNitro10.787s (~)11.013s (~)0.226s51.02x
🌐 TursoNext.js (Turbopack)12.192s (~)13.020s (~)0.828s51.15x
🌐 MongoDBNext.js (Turbopack)12.197s (~)13.019s (~)0.822s51.15x
🐘 PostgresExpress16.081s (-21.2% 🟢)16.837s (-20.0% 🟢)0.756s51.52x
🐘 PostgresNitro18.950s (-6.9% 🟢)19.634s (-5.8% 🟢)0.683s51.79x
🐘 PostgresNext.js (Turbopack)20.287s (+33.3% 🔺)21.046s (+31.2% 🔺)0.760s51.92x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro21.714s (+0.7%)22.834s (+1.8%)1.120s51.00x
▲ VercelExpress21.984s (+1.6%)23.013s (+3.2%)1.029s51.01x
▲ VercelNext.js (Turbopack)22.185s (+1.8%)23.116s (+2.5%)0.932s51.02x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Starter🥇 Next.js (Turbopack)1.334s (~)2.007s (~)0.673s151.00x
🌐 RedisNext.js (Turbopack)1.365s (+0.6%)2.011s (~)0.646s151.02x
💻 LocalNext.js (Turbopack)1.373s (-1.6%)2.010s (~)0.636s151.03x
💻 LocalNitro1.408s (~)2.005s (~)0.597s151.06x
💻 LocalExpress1.408s (~)2.006s (~)0.598s151.06x
🐘 PostgresNext.js (Turbopack)1.901s (+3.1%)2.164s (+4.7%)0.263s141.43x
🐘 PostgresExpress1.939s (-18.7% 🟢)2.225s (-26.1% 🟢)0.287s141.45x
🌐 MongoDBNext.js (Turbopack)2.130s (~)3.011s (~)0.881s101.60x
🌐 TursoNext.js (Turbopack)2.198s (~)3.014s (~)0.815s101.65x
🐘 PostgresNitro2.351s (~)2.921s (-3.0%)0.569s111.76x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.844s (+4.8%)3.777s (-0.9%)0.933s81.00x
▲ VercelNext.js (Turbopack)2.994s (-1.9%)3.767s (-5.3% 🟢)0.774s81.05x
▲ VercelExpress3.634s (+32.8% 🔺)4.564s (+29.8% 🔺)0.930s71.28x

🔍 Observability: Nitro | Next.js (Turbopack) | Express

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)2.081s (-3.8%)2.911s (-6.6% 🟢)0.830s111.00x
💻 LocalExpress2.206s (~)3.160s (~)0.954s101.06x
💻 LocalNitro2.224s (~)3.169s (~)0.945s101.07x
🌐 StarterNext.js (Turbopack)2.448s (~)3.009s (~)0.560s101.18x
🌐 RedisNext.js (Turbopack)2.506s (+0.5%)3.012s (~)0.507s101.20x
🐘 PostgresExpress2.704s (-7.4% 🟢)3.016s (-6.8% 🟢)0.312s101.30x
🐘 PostgresNitro2.713s (+2.2%)3.012s (-0.5%)0.298s101.30x
🐘 PostgresNext.js (Turbopack)2.920s (+10.9% 🔺)3.411s (+12.5% 🔺)0.491s91.40x
🌐 MongoDBNext.js (Turbopack)4.685s (-1.8%)5.180s (~)0.495s62.25x
🌐 TursoNext.js (Turbopack)4.704s (+0.7%)5.178s (~)0.474s62.26x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.558s (+16.9% 🔺)4.236s (+15.0% 🔺)0.678s81.00x
▲ VercelNext.js (Turbopack)3.664s (+15.3% 🔺)4.434s (+16.2% 🔺)0.770s71.03x
▲ VercelNitro3.779s (+21.7% 🔺)4.309s (+12.4% 🔺)0.530s91.06x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Starter🥇 Next.js (Turbopack)1.350s (-1.6%)2.007s (~)0.656s151.00x
🌐 RedisNext.js (Turbopack)1.371s (-1.1%)2.009s (~)0.638s151.02x
💻 LocalNext.js (Turbopack)1.388s (-0.7%)2.013s (~)0.625s151.03x
💻 LocalExpress1.408s (~)2.006s (~)0.598s151.04x
💻 LocalNitro1.431s (~)2.005s (~)0.574s151.06x
🐘 PostgresExpress1.706s (-3.4%)2.009s (~)0.303s151.26x
🐘 PostgresNitro1.775s (-5.3% 🟢)2.011s (~)0.236s151.31x
🐘 PostgresNext.js (Turbopack)2.026s (+24.5% 🔺)2.231s (+10.8% 🔺)0.206s141.50x
🌐 MongoDBNext.js (Turbopack)2.176s (+2.7%)3.011s (~)0.835s101.61x
🌐 TursoNext.js (Turbopack)2.233s (+0.7%)3.015s (~)0.782s101.65x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.872s (+7.4% 🔺)3.757s (~)0.885s81.00x
▲ VercelExpress2.879s (+6.2% 🔺)4.136s (+17.9% 🔺)1.257s81.00x
▲ VercelNext.js (Turbopack)2.974s (+11.8% 🔺)4.393s (+20.0% 🔺)1.419s71.04x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)2.056s (-10.4% 🟢)2.916s (-10.1% 🟢)0.860s111.00x
💻 LocalExpress2.221s (-1.0%)3.152s (-1.1%)0.931s101.08x
💻 LocalNitro2.249s (-5.9% 🟢)3.178s (-4.6%)0.929s101.09x
🌐 StarterNext.js (Turbopack)2.458s (~)3.010s (~)0.552s101.20x
🐘 PostgresExpress2.502s (-8.1% 🟢)3.017s (~)0.515s101.22x
🌐 RedisNext.js (Turbopack)2.508s (+0.8%)3.012s (~)0.504s101.22x
🐘 PostgresNext.js (Turbopack)2.734s (+3.0%)3.028s (~)0.294s101.33x
🐘 PostgresNitro2.791s (+19.2% 🔺)3.038s (+0.9%)0.246s101.36x
🌐 TursoNext.js (Turbopack)4.693s (-1.6%)5.181s (~)0.488s62.28x
🌐 MongoDBNext.js (Turbopack)4.748s (+0.9%)5.179s (~)0.431s62.31x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.181s (+7.2% 🔺)3.741s (+1.6%)0.559s91.00x
▲ VercelExpress3.352s (+4.0%)3.953s (+3.9%)0.601s81.05x
▲ VercelNext.js (Turbopack)3.540s (+10.7% 🔺)4.341s (+11.9% 🔺)0.800s71.11x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🌐 Starter🥇 Next.js (Turbopack)0.125s (-3.2%)1.005s (~)0.000s (+Infinity% 🔺)1.010s (~)0.885s101.00x
💻 LocalNext.js (Turbopack)0.135s (-8.2% 🟢)1.003s (~)0.019s (+14.1% 🔺)1.029s (~)0.895s101.08x
🌐 RedisNext.js (Turbopack)0.141s (-1.1%)1.005s (~)0.000s (+Infinity% 🔺)1.013s (~)0.872s101.13x
💻 LocalNitro0.176s (~)0.992s (~)0.015s (-6.9% 🟢)1.021s (~)0.845s101.41x
💻 LocalExpress0.176s (~)0.992s (~)0.016s (~)1.022s (~)0.846s101.41x
🌐 TursoNext.js (Turbopack)0.461s (-5.9% 🟢)0.985s (+2.5%)0.000s (~)1.012s (~)0.551s103.69x
🌐 MongoDBNext.js (Turbopack)0.523s (+1.9%)0.925s (-1.1%)0.000s (+Infinity% 🔺)1.013s (~)0.490s104.18x
🐘 PostgresExpress1.353s (-41.6% 🟢)1.687s (-38.1% 🟢)0.000s (+200.0% 🔺)2.012s (-33.3% 🟢)0.659s1010.81x
🐘 PostgresNitro1.370s (-39.7% 🟢)1.670s (-39.7% 🟢)0.000s (-100.0% 🟢)2.012s (-33.2% 🟢)0.642s1010.95x
🐘 PostgresNext.js (Turbopack)1.608s (+35.8% 🔺)2.296s (+34.4% 🔺)0.000s (+Infinity% 🔺)2.418s (+32.9% 🔺)0.811s1012.85x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.631s (-11.4% 🟢)3.110s (-3.9%)0.657s (-8.1% 🟢)4.223s (-6.0% 🟢)1.592s101.00x
▲ VercelNitro2.770s (+4.7%)3.322s (+3.1%)0.567s (-15.4% 🟢)4.412s (+2.5%)1.643s101.05x
▲ VercelNext.js (Turbopack)2.788s (+6.0% 🔺)3.253s (+1.8%)0.832s (+45.0% 🔺)4.510s (+7.5% 🔺)1.722s101.06x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNext.js (Turbopack)8/8
🐘 PostgresExpress5/8
▲ VercelNitro4/8
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express💻 Local8/8
Next.js (Turbopack)🌐 Starter6/8
Nitro💻 Local8/8
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Starter: Community world (local development)
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)

📋 View full workflow run

@github-actions

github-actionsBot commented Dec 20, 2025

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production286011297
✅ 💻 Local Development26208270
✅ 📦 Local Production26208270
✅ 🐘 Local Postgres26208270
✅ 🪟 Windows270027
❌ 🌍 Community Worlds109110120
Total120811351254

❌ Failed Tests

🌍 Community Worlds (11 failed)

mongodb (1 failed):

  • webhookWorkflow

redis (1 failed):

  • webhookWorkflow

starter (8 failed):

  • addTenWorkflow
  • addTenWorkflow
  • retryAttemptCounterWorkflow
  • crossFileErrorWorkflow - stack traces work across imported modules
  • hookCleanupTestWorkflow - hook token reuse after workflow completion
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step

turso (1 failed):

  • webhookWorkflow

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro2601
✅ example2601
✅ express2601
✅ fastify2601
✅ hono2601
✅ nextjs-turbopack2601
✅ nextjs-webpack2601
✅ nitro2601
✅ nuxt2601
✅ sveltekit2601
✅ vite2601
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable2601
✅ express-stable2601
✅ fastify-stable2601
✅ hono-stable2601
✅ nextjs-turbopack-stable2700
✅ nextjs-webpack-stable2700
✅ nitro-stable2601
✅ nuxt-stable2601
✅ sveltekit-stable2601
✅ vite-stable2601
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable2601
✅ express-stable2601
✅ fastify-stable2601
✅ hono-stable2601
✅ nextjs-turbopack-stable2700
✅ nextjs-webpack-stable2700
✅ nitro-stable2601
✅ nuxt-stable2601
✅ sveltekit-stable2601
✅ vite-stable2601
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable2601
✅ express-stable2601
✅ fastify-stable2601
✅ hono-stable2601
✅ nextjs-turbopack-stable2700
✅ nextjs-webpack-stable2700
✅ nitro-stable2601
✅ nuxt-stable2601
✅ sveltekit-stable2601
✅ vite-stable2601
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack2700
❌ 🌍 Community Worlds
AppPassedFailedSkipped
✅ mongodb-dev300
❌ mongodb2610
✅ redis-dev300
❌ redis2610
✅ starter-dev300
❌ starter1980
✅ turso-dev300
❌ turso2610

📋 View full workflow run

@vercel

vercelBot commented Dec 20, 2025

Copy link
Copy Markdown
Contributor

@TooTallNateGraphite App

TooTallNate commented Dec 20, 2025

Copy link
Copy Markdown
MemberAuthor

Comment on lines +356 to +362
} catch (err) {
state.streamEnded = true;
if (!state.doneResolved) {
state.doneResolved = true;
state.reject(err);
}
throw err;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stream locks are not released when an error occurs in the pipe. The error is thrown without releasing the locks acquired at the start of the function.

View Details
📝 Patch Details
diff --git a/packages/core/src/serialization.ts b/packages/core/src/serialization.ts
index c8ebc73..23e2ae6 100644
--- a/packages/core/src/serialization.ts+++ b/packages/core/src/serialization.ts@@ -359,6 +359,8 @@ async function flushablePipe(
state.doneResolved = true;
state.reject(err);
}
+ reader.releaseLock();+ writer.releaseLock();
throw err;
}
}

Analysis

Stream locks not released when error occurs in flushablePipe

What fails: The flushablePipe() function in packages/core/src/serialization.ts does not release the reader and writer locks when an error is thrown in the catch block (lines 356-362), leaving the streams in a permanently locked state.

How to reproduce: Create a test scenario where an error occurs during pipe operation:

// Create a readable streamconstsource=newReadableStream({start(controller){controller.enqueue('chunk1');controller.enqueue('chunk2');controller.close();}});// Create a writable stream that errors on second writeletwriteCount=0;constsink=newWritableStream({asyncwrite(chunk){writeCount++;if(writeCount===2){thrownewError('Write failed!');}}});// Call flushablePipe with error stateconststate={pendingOps: 0,doneResolved: false,streamEnded: false,resolve: ()=>{},reject: ()=>{}};try{awaitflushablePipe(source,sink,state);}catch(err){// Error caught}// Attempt to acquire new reader/writersource.getReader();// Throws: "InvalidStateError: ReadableStream is locked"sink.getWriter();// Throws: "InvalidStateError: WritableStream is locked"

Result: Streams remain locked (source.locked === true, sink.locked === true), preventing new readers/writers from being obtained. Cannot acquire new reader: "InvalidStateError: ReadableStream is locked". Cannot acquire new writer: "InvalidStateError: WritableStream is locked".

Expected behavior: The locks should be released before throwing the error, as they are in normal code paths (lines 326, 339, 346). Per WHATWG Streams API specification, a lock must be explicitly released, and a locked stream prevents other readers/writers from being obtained. Leaving locks unreleased creates a resource leak that prevents stream reuse.

Root cause: The catch block at lines 356-362 sets error state and throws without calling reader.releaseLock() and writer.releaseLock(), unlike all other exit paths from the function which properly release locks.

Fix applied: Added reader.releaseLock() and writer.releaseLock() before the throw err statement in the catch block to match the cleanup pattern used in normal code paths.

Comment on lines +329 to +338
if (readResult.done) {
// Source stream completed - close sink and resolve
state.streamEnded = true;
await writer.close();
// Resolve done promise if not already resolved
if (!state.doneResolved) {
state.doneResolved = true;
state.resolve();
}
return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stream locks are not released when the source stream ends. The flushablePipe function acquires locks on the reader and writer but doesn't release them before returning when the readable stream completes.

View Details
📝 Patch Details
diff --git a/packages/core/src/serialization.ts b/packages/core/src/serialization.ts
index c8ebc73..57e4750 100644
--- a/packages/core/src/serialization.ts+++ b/packages/core/src/serialization.ts@@ -335,6 +335,8 @@ async function flushablePipe(
state.doneResolved = true;
state.resolve();
}
+ reader.releaseLock();+ writer.releaseLock();
return;
}
@@ -359,6 +361,8 @@ async function flushablePipe(
state.doneResolved = true;
state.reject(err);
}
+ reader.releaseLock();+ writer.releaseLock();
throw err;
}
}

Analysis

Stream locks not released in flushablePipe() when source completes or errors

What fails: The flushablePipe() function in packages/core/src/serialization.ts (lines 333-341 and 365-371) acquires locks on ReadableStream and WritableStream but fails to release them before returning when the stream completes or when an error occurs.

How to reproduce:

// When readResult.done is true (source stream completed):constreadResult=awaitreader.read();if(readResult.done){state.streamEnded=true;awaitwriter.close();// ... resolve promise ...return;// ❌ Returns without releasing locks}// Or when error occurs:}catch(err){state.streamEnded=true;// ... reject promise ...throwerr;// ❌ Throws without releasing locks}

Result: Unreleased locks prevent any other code from acquiring new readers or writers on these streams. Subsequent attempts to call getReader() or getWriter() will throw TypeError: ReadableStreamDefaultReader constructor can only accept readable streams that are not yet locked to a reader. The stream becomes permanently inaccessible.

Expected behavior: According to MDN Web JavaScript resource management, unreleased locks cause the stream to remain locked indefinitely and "does not permit another reader to consume it." The locks must be explicitly released via reader.releaseLock() and writer.releaseLock() before the function exits.

Fix: Added reader.releaseLock() and writer.releaseLock() calls before all return/throw statements in the flushablePipe function:

  • Before returning when readResult.done (normal stream completion)
  • Before throwing in the catch block (error case)

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements a polling mechanism to detect when stream locks are released, allowing workflow steps to complete without waiting for streams to fully close. This fixes an issue where Vercel functions would hang when users write to streams and release locks without explicitly closing them.

Key Changes:

  • Added flushablePipe function that resolves when either the stream completes or when the user releases their lock with all pending writes flushed
  • Implemented polling functions (pollWritableLock and pollReadableLock) that check stream lock state every 100ms
  • Created state tracking system (FlushableStreamState) to monitor pending operations and resolution status

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 15 comments.

FileDescription
packages/core/src/serialization.tsImplements the core flushable pipe mechanism with polling-based lock detection and state tracking for both readable and writable streams
packages/core/src/serialization.test.tsAdds tests verifying the new behavior for both lock release and natural stream closure scenarios
.changeset/stream-lock-polling.mdDocuments the patch-level change for the @workflow/core package

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +1174 to +1176
flushablePipe(readable, transform.writable, state).catch(() => {
// Errors are handled via state.reject
});

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The flushablePipe function throws errors after calling state.reject() (line 405), but all call sites catch and ignore these thrown errors with empty catch blocks (e.g., line 976, 992, 1016). This pattern is confusing because the error is both rejected through the state AND thrown. Since the error is already propagated via state.reject() which rejects the 'done' promise, the re-throw serves no purpose and the empty catch blocks make this unclear. Consider either removing the throw from flushablePipe or documenting why the error needs to be both rejected and thrown.

Copilot uses AI. Check for mistakes.
Comment on lines +399 to +406
} catch (err) {
state.streamEnded = true;
if (!state.doneResolved) {
state.doneResolved = true;
state.reject(err);
}
throw err;
}

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error handling in the flushablePipe function could potentially leak resources. When an exception occurs in the catch block at line 399, the reader and writer locks are not released before the error is re-thrown. This could leave the streams in a locked state. Consider releasing the locks in the catch block before re-throwing.

Copilot uses AI. Check for mistakes.
Comment on lines +229 to +261
/**
* Checks if a WritableStream is unlocked (user released lock) vs closed.
* When a stream is closed, .locked is false but getWriter() throws.
* We only want to resolve via polling when the stream is unlocked, not closed.
* If closed, the pump will handle resolution via the stream ending naturally.
*/
function isWritableUnlockedNotClosed(writable: WritableStream): boolean {
if (writable.locked) return false;

try {
// Try to acquire writer - if successful, stream is unlocked (not closed)
const writer = writable.getWriter();
writer.releaseLock();
return true;
} catch {
// getWriter() throws if stream is closed/errored - let pump handle it
return false;
}
}

/**
* Checks if a ReadableStream is unlocked (user released lock) vs closed.
*/
function isReadableUnlockedNotClosed(readable: ReadableStream): boolean {
if (readable.locked) return false;

try {
// Try to acquire reader - if successful, stream is unlocked (not closed)
const reader = readable.getReader();
reader.releaseLock();
return true;
} catch {
// getReader() throws if stream is closed/errored - let pump handle it

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The polling functions acquire and release stream locks every 100ms to check if a stream is unlocked vs closed. This repeated lock acquisition could have performance implications and might interfere with stream operations. Consider using a different approach to detect stream closure, such as checking for specific error messages or using a flag that's set when the stream closes, to avoid repeatedly acquiring locks during polling.

Suggested change
/**
*ChecksifaWritableStreamisunlocked(userreleasedlock)vsclosed.
*Whenastreamisclosed,.lockedisfalsebutgetWriter()throws.
*Weonlywanttoresolveviapollingwhenthestreamisunlocked,notclosed.
*Ifclosed,thepumpwillhandleresolutionviathestreamendingnaturally.
*/
functionisWritableUnlockedNotClosed(writable: WritableStream): boolean{
if(writable.locked)returnfalse;
try{
// Try to acquire writer - if successful, stream is unlocked (not closed)
constwriter=writable.getWriter();
writer.releaseLock();
returntrue;
}catch{
// getWriter() throws if stream is closed/errored - let pump handle it
returnfalse;
}
}
/**
*ChecksifaReadableStreamisunlocked(userreleasedlock)vsclosed.
*/
functionisReadableUnlockedNotClosed(readable: ReadableStream): boolean{
if(readable.locked)returnfalse;
try{
// Try to acquire reader - if successful, stream is unlocked (not closed)
constreader=readable.getReader();
reader.releaseLock();
returntrue;
}catch{
// getReader() throws if stream is closed/errored - let pump handle it
/**
*Cacheofper-stream"unlocked but not closed"statusforWritableStreams.
*Thisavoidsrepeatedlyacquiringandreleasinglocksduringpolling;we
*probethestreamatmostonceperinstancewhenittransitionstounlocked.
*/
constwritableUnlockedNotClosedCache=newWeakMap<WritableStream,boolean>();
/**
*ChecksifaWritableStreamisunlocked(userreleasedlock)vsclosed.
*Whenastreamisclosed,.lockedisfalsebutgetWriter()throws.
*Weonlywanttoresolveviapollingwhenthestreamisunlocked,notclosed.
*Ifclosed,thepumpwillhandleresolutionviathestreamendingnaturally.
*
*Toavoidrepeatedlockacquisitionduringpolling,wecachetheresultof
*theunlocked-vs-closedprobeperstream.Oncedetermined,thestatus is
*reusedonsubsequentcallsforthesamestream.
*/
functionisWritableUnlockedNotClosed(writable: WritableStream): boolean{
if(writable.locked)returnfalse;
constcached=writableUnlockedNotClosedCache.get(writable);
if(cached!==undefined){
returncached;
}
try{
// Try to acquire writer - if successful, stream is unlocked (not closed)
constwriter=writable.getWriter();
writer.releaseLock();
writableUnlockedNotClosedCache.set(writable,true);
returntrue;
}catch{
// getWriter() throws if stream is closed/errored - let pump handle it
writableUnlockedNotClosedCache.set(writable,false);
returnfalse;
}
}
/**
*Cacheofper-stream"unlocked but not closed"statusforReadableStreams.
*Likethewritablecache,thispreventsrepeatedreaderacquisitionswhile
*pollingforlockrelease.
*/
constreadableUnlockedNotClosedCache=newWeakMap<ReadableStream,boolean>();
/**
*ChecksifaReadableStreamisunlocked(userreleasedlock)vsclosed.
*Usesaper-streamcachetoavoidrepeatedlyacquiringandreleasinglocks
*duringpolling.
*/
functionisReadableUnlockedNotClosed(readable: ReadableStream): boolean{
if(readable.locked)returnfalse;
constcached=readableUnlockedNotClosedCache.get(readable);
if(cached!==undefined){
returncached;
}
try{
// Try to acquire reader - if successful, stream is unlocked (not closed)
constreader=readable.getReader();
reader.releaseLock();
readableUnlockedNotClosedCache.set(readable,true);
returntrue;
}catch{
// getReader() throws if stream is closed/errored - let pump handle it
readableUnlockedNotClosedCache.set(readable,false);

Copilot uses AI. Check for mistakes.
Comment on lines +1179 to +1186
} catch (err) {
state.streamEnded = true;
if (!state.doneResolved) {
state.doneResolved = true;
state.reject(err as Error);
}
}
})();

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error handling in the test's pipe implementation (line 1179-1185) doesn't release the reader and writer locks before exiting, similar to the issue in the actual flushablePipe implementation. While this mirrors the production code, it would be better to release the locks in the catch block to properly clean up resources. Consider updating the test to match the corrected implementation once the production code is fixed.

Copilot uses AI. Check for mistakes.
Comment on lines +1016 to +1018
flushablePipe(serialize.readable, serverWritable, state).catch(() => {
// Errors are handled via state.reject
});

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The flushablePipe function throws errors after calling state.reject() (line 405), but all call sites catch and ignore these thrown errors with empty catch blocks (e.g., line 976, 992, 1016). This pattern is confusing because the error is both rejected through the state AND thrown. Since the error is already propagated via state.reject() which rejects the 'done' promise, the re-throw serves no purpose and the empty catch blocks make this unclear. Consider either removing the throw from flushablePipe or documenting why the error needs to be both rejected and thrown.

Suggested change
flushablePipe(serialize.readable,serverWritable,state).catch(()=>{
// Errors are handled via state.reject
});
flushablePipe(serialize.readable,serverWritable,state).catch(
(error)=>{
// flushablePipe already calls state.reject(error), which rejects the
// `done` promise added to `ops`. We catch and intentionally ignore
// this rejection here only to avoid an unhandled promise rejection;
// all real error handling flows through state.done.
}
);

Copilot uses AI. Check for mistakes.
Comment on lines +1001 to +1229
describe('flushable stream behavior', () => {
const POLL_INTERVAL = 100; // Match the actual implementation
const STABLE_POLL_COUNT = 2; // Match the actual implementation

it('done promise should resolve when writable stream lock is released (polling)', async () => {
// Test the pattern: user writes, releases lock, polling detects it, done resolves
const chunks: string[] = [];
let streamClosed = false;

// Create a simple mock for the sink
const mockSink = new WritableStream<string>({
write(chunk) {
chunks.push(chunk);
},
close() {
streamClosed = true;
},
});

// Create a TransformStream like we do in getStepRevivers
const { readable, writable } = new TransformStream<string, string>();

// Track flushable state - this mirrors the actual implementation
const state = {
pendingOps: 0, // Only counts writes to server
doneResolved: false,
streamEnded: false,
resolve: () => {},
reject: (_err: Error) => {},
};

const done = new Promise<void>((res, rej) => {
state.resolve = res;
state.reject = rej;
});

// Start piping in background (mirrors flushablePipe implementation)
(async () => {
const reader = readable.getReader();
const writer = mockSink.getWriter();
try {
while (!state.streamEnded) {
const result = await reader.read();

if (result.done) {
state.streamEnded = true;
await writer.close();
if (!state.doneResolved) {
state.doneResolved = true;
state.resolve();
}
return;
}

// Only writes count as pending ops
state.pendingOps++;
await writer.write(result.value);
state.pendingOps--;

if (state.streamEnded) {
reader.releaseLock();
writer.releaseLock();
return;
}
}
} catch (err) {
state.streamEnded = true;
if (!state.doneResolved) {
state.doneResolved = true;
state.reject(err as Error);
}
}
})();

// Start polling (mirrors pollWritableLock implementation)
let stableCount = 0;
const intervalId = setInterval(() => {
if (state.doneResolved || state.streamEnded) {
clearInterval(intervalId);
return;
}

// Check if lock is released by checking .locked property
if (!writable.locked && state.pendingOps === 0) {
stableCount++;
if (stableCount >= STABLE_POLL_COUNT) {
state.doneResolved = true;
state.resolve();
clearInterval(intervalId);
}
} else {
stableCount = 0;
}
}, POLL_INTERVAL);

// Simulate user interaction - write and release lock
const userWriter = writable.getWriter();
await userWriter.write('chunk1');
await userWriter.write('chunk2');

// Release lock without closing stream
userWriter.releaseLock();

// Wait for pipe to process + polling intervals (need STABLE_POLL_COUNT consecutive polls)
await new Promise((r) => setTimeout(r, 250));

// The done promise should resolve
await expect(
Promise.race([
done,
new Promise((_, r) => setTimeout(() => r(new Error('timeout')), 400)),
])
).resolves.toBeUndefined();

// Chunks should have been written
expect(chunks).toContain('chunk1');
expect(chunks).toContain('chunk2');

// Stream should NOT be closed (user only released lock)
expect(streamClosed).toBe(false);
});

it('done promise should resolve when writable stream closes naturally', async () => {
const chunks: string[] = [];
let streamClosed = false;

const mockSink = new WritableStream<string>({
write(chunk) {
chunks.push(chunk);
},
close() {
streamClosed = true;
},
});

const { readable, writable } = new TransformStream<string, string>();

const state = {
pendingOps: 0,
doneResolved: false,
streamEnded: false,
resolve: () => {},
reject: (_err: Error) => {},
};

const done = new Promise<void>((res, rej) => {
state.resolve = res;
state.reject = rej;
});

// Start piping in background
(async () => {
const reader = readable.getReader();
const writer = mockSink.getWriter();
try {
while (!state.streamEnded) {
const result = await reader.read();

if (result.done) {
state.streamEnded = true;
await writer.close();
if (!state.doneResolved) {
state.doneResolved = true;
state.resolve();
}
return;
}

state.pendingOps++;
await writer.write(result.value);
state.pendingOps--;

if (state.streamEnded) {
reader.releaseLock();
writer.releaseLock();
return;
}
}
} catch (err) {
state.streamEnded = true;
if (!state.doneResolved) {
state.doneResolved = true;
state.reject(err as Error);
}
}
})();

// Start polling (won't trigger since stream will close first)
let stableCount2 = 0;
const intervalId = setInterval(() => {
if (state.doneResolved || state.streamEnded) {
clearInterval(intervalId);
return;
}
if (!writable.locked && state.pendingOps === 0) {
stableCount2++;
if (stableCount2 >= STABLE_POLL_COUNT) {
state.doneResolved = true;
state.resolve();
clearInterval(intervalId);
}
} else {
stableCount2 = 0;
}
}, POLL_INTERVAL);

// User writes and then closes the stream
const userWriter = writable.getWriter();
await userWriter.write('data');
await userWriter.close();

// Wait a tick for the pipe to process
await new Promise((r) => setTimeout(r, 50));

// The done promise should resolve
await expect(
Promise.race([
done,
new Promise((_, r) => setTimeout(() => r(new Error('timeout')), 200)),
])
).resolves.toBeUndefined();

// Chunks should have been written
expect(chunks).toContain('data');

// Stream should be closed (user closed it)
expect(streamClosed).toBe(true);
});
});

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new flushable stream behavior tests only cover the happy paths (lock release and normal closure). Consider adding test cases for error scenarios such as: stream errors during write operations, reader errors, and what happens when the stream is aborted. This would help ensure the error handling in flushablePipe and the polling mechanisms work correctly under failure conditions.

Copilot uses AI. Check for mistakes.
Comment on lines +1158 to +1160
flushablePipe(readable, writable, state).catch(() => {
// Errors are handled via state.reject
});

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The flushablePipe function throws errors after calling state.reject() (line 405), but all call sites catch and ignore these thrown errors with empty catch blocks (e.g., line 976, 992, 1016). This pattern is confusing because the error is both rejected through the state AND thrown. Since the error is already propagated via state.reject() which rejects the 'done' promise, the re-throw serves no purpose and the empty catch blocks make this unclear. Consider either removing the throw from flushablePipe or documenting why the error needs to be both rejected and thrown.

Copilot uses AI. Check for mistakes.
Comment on lines +192 to +196
* - `doneResolved`: The `done` promise has been resolved (step can complete)
* - `streamEnded`: The underlying stream has actually closed/errored
*
* The pump continues running even after `doneResolved=true` to handle
* any future writes if the user acquires a new lock.

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment states that "The pump continues running even after doneResolved=true to handle any future writes if the user acquires a new lock." However, this design could be problematic. Once the done promise resolves (pushed to ops), the workflow step will complete. If the user then acquires a new lock and writes more data after the step has completed, it could lead to unexpected behavior where data is being written to a stream associated with an already-completed step. Consider whether this is the intended behavior or if the stream should be closed/locked after the done promise resolves.

Suggested change
*-`doneResolved`: The`done`promisehasbeenresolved(stepcancomplete)
*-`streamEnded`: Theunderlyingstreamhasactuallyclosed/errored
*
*Thepumpcontinuesrunningevenafter`doneResolved=true`tohandle
* any futurewritesiftheuseracquiresanewlock.
*-`doneResolved`: The`done`promisehasbeenresolved(stepcancomplete),
*andnofurtherwritesshouldbeacceptedforthisstream.
*-`streamEnded`: Theunderlyingstreamhasactuallyclosed/errored.
*
*Once`doneResolved`becomes`true`,thepumpshouldonlydrainany
*already-pendingoperationsandthenstop;itmustnothandlenewwrites
*fromsubsequentlyacquiredlocks.

Copilot uses AI. Check for mistakes.
Comment on lines +1066 to +1073
} catch (err) {
state.streamEnded = true;
if (!state.doneResolved) {
state.doneResolved = true;
state.reject(err as Error);
}
}
})();

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error handling in the test's pipe implementation (line 1066-1072) doesn't release the reader and writer locks before exiting, similar to the issue in the actual flushablePipe implementation. While this mirrors the production code, it would be better to release the locks in the catch block to properly clean up resources. Consider updating the test to match the corrected implementation once the production code is fixed.

Copilot uses AI. Check for mistakes.
Comment on lines +1189 to +1203
let stableCount2 = 0;
const intervalId = setInterval(() => {
if (state.doneResolved || state.streamEnded) {
clearInterval(intervalId);
return;
}
if (!writable.locked && state.pendingOps === 0) {
stableCount2++;
if (stableCount2 >= STABLE_POLL_COUNT) {
state.doneResolved = true;
state.resolve();
clearInterval(intervalId);
}
} else {
stableCount2 = 0;

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The variable name stableCount2 is unclear and suggests copy-paste from the first test. Consider renaming to stableCount for consistency, as the numbering doesn't add clarity and makes the code harder to read.

Suggested change
letstableCount2=0;
constintervalId=setInterval(()=>{
if(state.doneResolved||state.streamEnded){
clearInterval(intervalId);
return;
}
if(!writable.locked&&state.pendingOps===0){
stableCount2++;
if(stableCount2>=STABLE_POLL_COUNT){
state.doneResolved=true;
state.resolve();
clearInterval(intervalId);
}
}else{
stableCount2=0;
letstableCount=0;
constintervalId=setInterval(()=>{
if(state.doneResolved||state.streamEnded){
clearInterval(intervalId);
return;
}
if(!writable.locked&&state.pendingOps===0){
stableCount++;
if(stableCount>=STABLE_POLL_COUNT){
state.doneResolved=true;
state.resolve();
clearInterval(intervalId);
}
}else{
stableCount=0;

Copilot uses AI. Check for mistakes.

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before I review - there's a ton of AI comments on this PR and I want to give you a chance to resolve those, so I'm subscribing and re-reviewing after the next commit/message

Base automatically changed from 12-22-fix_race_conditions_in_webhook_response_handling to mainDecember 22, 2025 18:41
@TooTallNate

Copy link
Copy Markdown
MemberAuthor

@copilot Please evaluate the code review suggestions in this PR and apply them if you think they are relevent.

CopilotAI commented Dec 22, 2025

Copy link
Copy Markdown
Contributor

@TooTallNate I've opened a new pull request, #677, to work on those changes. Once the pull request is ready, I'll request review from you.

…f waiting for stream to close
This prevents Vercel functions from hanging when users incrementally write to streams within steps (e.g., `await writer.write(data); writer.releaseLock()`). Uses a polling approach to detect when the stream lock is released and all pending writes are flushed.
Comment on lines +271 to +316
function pollWritableLock(
writable: WritableStream,
state: FlushableStreamState
): void {
const intervalId = setInterval(() => {
// Stop polling if already resolved or stream ended
if (state.doneResolved || state.streamEnded) {
clearInterval(intervalId);
return;
}

// Check if lock is released (not closed) and no pending ops
if (isWritableUnlockedNotClosed(writable) && state.pendingOps === 0) {
state.doneResolved = true;
state.resolve();
clearInterval(intervalId);
}
}, LOCK_POLL_INTERVAL_MS);
}

/**
* Polls a ReadableStream to check if the user has released their lock.
* Resolves the done promise when lock is released and no pending ops remain.
*
* Note: Only resolves if stream is unlocked but NOT closed. If the user closes
* the stream, the pump will handle resolution via the stream ending naturally.
*/
function pollReadableLock(
readable: ReadableStream,
state: FlushableStreamState
): void {
const intervalId = setInterval(() => {
// Stop polling if already resolved or stream ended
if (state.doneResolved || state.streamEnded) {
clearInterval(intervalId);
return;
}

// Check if lock is released (not closed) and no pending ops
if (isReadableUnlockedNotClosed(readable) && state.pendingOps === 0) {
state.doneResolved = true;
state.resolve();
clearInterval(intervalId);
}
}, LOCK_POLL_INTERVAL_MS);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The polling functions don't implement the "multiple consecutive polls" behavior mentioned in the docstring, creating a potential race condition where the done promise resolves before the pump finishes writing queued data.

View Details
📝 Patch Details
diff --git a/packages/core/src/serialization.ts b/packages/core/src/serialization.ts
index 69c391b..376afe4 100644
--- a/packages/core/src/serialization.ts+++ b/packages/core/src/serialization.ts@@ -182,6 +182,9 @@ export class WorkflowServerWritableStream extends WritableStream<Uint8Array> {
/** Polling interval for checking stream lock state (in milliseconds) */
const LOCK_POLL_INTERVAL_MS = 100;
+/** Number of consecutive stable polls before resolving done promise */+const STABLE_POLL_COUNT = 2;+
/**
* State tracker for flushable stream operations.
* Resolves when either:
@@ -272,6 +275,7 @@ function pollWritableLock(
writable: WritableStream,
state: FlushableStreamState
): void {
+ let stableCount = 0;
const intervalId = setInterval(() => {
// Stop polling if already resolved or stream ended
if (state.doneResolved || state.streamEnded) {
@@ -281,16 +285,22 @@ function pollWritableLock(
// Check if lock is released (not closed) and no pending ops
if (isWritableUnlockedNotClosed(writable) && state.pendingOps === 0) {
- state.doneResolved = true;- state.resolve();- clearInterval(intervalId);+ stableCount++;+ if (stableCount >= STABLE_POLL_COUNT) {+ state.doneResolved = true;+ state.resolve();+ clearInterval(intervalId);+ }+ } else {+ stableCount = 0;
}
}, LOCK_POLL_INTERVAL_MS);
}
/**
* Polls a ReadableStream to check if the user has released their lock.
- * Resolves the done promise when lock is released and no pending ops remain.+ * Resolves the done promise when lock is released and no pending ops remain+ * for multiple consecutive polls (to avoid race conditions with the pump).
*
* Note: Only resolves if stream is unlocked but NOT closed. If the user closes
* the stream, the pump will handle resolution via the stream ending naturally.
@@ -299,6 +309,7 @@ function pollReadableLock(
readable: ReadableStream,
state: FlushableStreamState
): void {
+ let stableCount = 0;
const intervalId = setInterval(() => {
// Stop polling if already resolved or stream ended
if (state.doneResolved || state.streamEnded) {
@@ -308,9 +319,14 @@ function pollReadableLock(
// Check if lock is released (not closed) and no pending ops
if (isReadableUnlockedNotClosed(readable) && state.pendingOps === 0) {
- state.doneResolved = true;- state.resolve();- clearInterval(intervalId);+ stableCount++;+ if (stableCount >= STABLE_POLL_COUNT) {+ state.doneResolved = true;+ state.resolve();+ clearInterval(intervalId);+ }+ } else {+ stableCount = 0;
}
}, LOCK_POLL_INTERVAL_MS);
}

Analysis

Race condition in polling functions: done promise resolves before pump finishes flushing

What fails: The pollWritableLock() and pollReadableLock() functions in packages/core/src/serialization.ts resolve the done promise immediately when the stream lock is released and pendingOps === 0, without waiting for multiple consecutive stable polls. This creates a race condition where the done promise can resolve while the pump is still reading/writing data.

How to reproduce:

  1. A stream is being piped asynchronously via flushablePipe()
  2. User releases the lock on the stream (but doesn't close it)
  3. Pump is momentarily between reads (at await reader.read()) with pendingOps === 0
  4. Polling fires and sees: lock released + pendingOps === 0 → resolves done promise immediately
  5. Runtime awaits Promise.all(ops) which includes the done promise and proceeds
  6. If pump reads more data and tries to write, the step is already marked complete
  7. This violates the contract that all writes must be flushed before the step completes

Expected behavior: Per the docstring at lines 265-266 and 293-294, polling should only resolve after the condition is stable for "multiple consecutive polls (to avoid race conditions with the pump)." The test file at lines 1002-1003 and 1084-1090 confirms this behavior by using STABLE_POLL_COUNT = 2 - only resolving after the unlock condition is seen 2 consecutive times.

Root cause: The actual implementation was missing the stability counter logic that the docstring promised and that the test expected. Added STABLE_POLL_COUNT constant and stability tracking to both polling functions.

Evidence:

  • Docstring promises: "for multiple consecutive polls (to avoid race conditions with the pump)"
  • Test implementation (lines 1084-1090): Implements stability counter that resets when condition changes
  • Test comment (line 1001): "mirrors the actual implementation"
  • Git history: Commit e8c3800 added polling with docstring but without stability logic

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@TooTallNate@VaguelySerious
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Fix stream serialization to resolve when user releases lock instead of waiting for stream to close - #661

Closed
TooTallNate wants to merge 2 commits into
mainfrom
12-20-fix_stream_serialization_to_resolve_when_user_releases_lock_instead_of_waiting_for_stream_to_close
Closed

Fix stream serialization to resolve when user releases lock instead of waiting for stream to close#661
TooTallNate wants to merge 2 commits into
mainfrom
12-20-fix_stream_serialization_to_resolve_when_user_releases_lock_instead_of_waiting_for_stream_to_close

Conversation

@TooTallNate

@TooTallNateTooTallNate commented Dec 20, 2025

Copy link
Copy Markdown
Member

Fix stream serialization to resolve when users release locks instead of waiting for streams to close, preventing Vercel functions from hanging.

What changed?

  • Implemented a polling mechanism to detect when stream locks are released
  • Added flushablePipe function that resolves in two scenarios:
    1. When the stream completes normally (close/error)
    2. When the user releases their lock AND all pending writes are flushed
  • Created a state tracking system to monitor pending operations and lock status

How to test?

  1. Create a workflow step that incrementally writes to a stream:

    constwriter=stream.getWriter();awaitwriter.write(data1);writer.releaseLock();// Step should complete here without waiting
  2. Verify the step completes immediately after lock release rather than hanging

  3. Run the new test cases that verify both lock release and normal stream closure behaviors

Why make this change?

This fixes an issue where Vercel functions would hang when users incrementally write to streams within steps. Previously, the system would wait for the stream to fully close before resolving, but many users follow a pattern where they write data and release the lock without explicitly closing the stream. This change allows steps to complete as soon as the user releases the lock and all pending writes are flushed, which is the expected behavior in most streaming scenarios.

@changeset-bot

changeset-botBot commented Dec 20, 2025

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 579162a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 12 packages
NameType
@workflow/corePatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/web-sharedPatch
workflowPatch
@workflow/astroPatch
@workflow/sveltekitPatch
@workflow/world-testingPatch
@workflow/nuxtPatch
@workflow/aiPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actionsBot commented Dec 20, 2025

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Starter🥇 Next.js (Turbopack)0.039s (+0.5%)1.013s (~)0.974s101.00x
💻 LocalNext.js (Turbopack)0.040s (-2.0%)1.017s (~)0.977s101.03x
💻 LocalNitro0.042s (-0.7%)1.007s (~)0.965s101.08x
🌐 RedisNext.js (Turbopack)0.042s (+0.7%)1.017s (~)0.975s101.08x
💻 LocalExpress0.043s (~)1.008s (~)0.965s101.11x
🌐 TursoNext.js (Turbopack)0.071s (-28.1% 🟢)1.014s (~)0.943s101.84x
🌐 MongoDBNext.js (Turbopack)0.107s (+44.9% 🔺)1.014s (~)0.907s102.76x
🐘 PostgresNext.js (Turbopack)0.128s (-21.4% 🟢)1.018s (~)0.890s103.31x
🐘 PostgresNitro0.343s (+13.9% 🔺)1.012s (~)0.669s108.87x
🐘 PostgresExpress0.369s (+16.2% 🔺)1.014s (~)0.645s109.54x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express0.552s (-9.8% 🟢)1.429s (-2.2%)0.877s101.00x
▲ VercelNext.js (Turbopack)0.556s (-12.4% 🟢)1.526s (-3.7%)0.970s101.01x
▲ VercelNitro0.569s (+8.1% 🔺)1.522s (-23.2% 🟢)0.953s101.03x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Starter🥇 Next.js (Turbopack)1.095s (+0.6%)2.009s (~)0.914s101.00x
💻 LocalNext.js (Turbopack)1.095s (~)2.012s (~)0.916s101.00x
🌐 RedisNext.js (Turbopack)1.097s (~)2.012s (~)0.915s101.00x
💻 LocalNitro1.110s (~)2.006s (~)0.896s101.01x
💻 LocalExpress1.112s (~)2.007s (~)0.895s101.02x
🌐 TursoNext.js (Turbopack)1.293s (-0.8%)2.012s (~)0.719s101.18x
🌐 MongoDBNext.js (Turbopack)1.309s (+0.8%)2.012s (~)0.703s101.20x
🐘 PostgresNitro2.196s (+2.5%)3.013s (~)0.817s102.01x
🐘 PostgresExpress2.207s (+1.9%)3.014s (~)0.807s102.02x
🐘 PostgresNext.js (Turbopack)2.458s (+27.9% 🔺)3.017s (+49.6% 🔺)0.559s102.25x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.619s (-2.6%)3.592s (-2.9%)0.973s101.00x
▲ VercelExpress2.626s (-1.6%)3.502s (-1.2%)0.876s101.00x
▲ VercelNitro2.669s (-2.4%)3.624s (-2.0%)0.955s101.02x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Starter🥇 Next.js (Turbopack)10.588s (~)11.013s (~)0.425s51.00x
💻 LocalNext.js (Turbopack)10.632s (~)11.016s (~)0.384s51.00x
🌐 RedisNext.js (Turbopack)10.678s (~)11.021s (~)0.342s51.01x
💻 LocalExpress10.781s (~)11.014s (~)0.234s51.02x
💻 LocalNitro10.787s (~)11.013s (~)0.226s51.02x
🌐 TursoNext.js (Turbopack)12.192s (~)13.020s (~)0.828s51.15x
🌐 MongoDBNext.js (Turbopack)12.197s (~)13.019s (~)0.822s51.15x
🐘 PostgresExpress16.081s (-21.2% 🟢)16.837s (-20.0% 🟢)0.756s51.52x
🐘 PostgresNitro18.950s (-6.9% 🟢)19.634s (-5.8% 🟢)0.683s51.79x
🐘 PostgresNext.js (Turbopack)20.287s (+33.3% 🔺)21.046s (+31.2% 🔺)0.760s51.92x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro21.714s (+0.7%)22.834s (+1.8%)1.120s51.00x
▲ VercelExpress21.984s (+1.6%)23.013s (+3.2%)1.029s51.01x
▲ VercelNext.js (Turbopack)22.185s (+1.8%)23.116s (+2.5%)0.932s51.02x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Starter🥇 Next.js (Turbopack)1.334s (~)2.007s (~)0.673s151.00x
🌐 RedisNext.js (Turbopack)1.365s (+0.6%)2.011s (~)0.646s151.02x
💻 LocalNext.js (Turbopack)1.373s (-1.6%)2.010s (~)0.636s151.03x
💻 LocalNitro1.408s (~)2.005s (~)0.597s151.06x
💻 LocalExpress1.408s (~)2.006s (~)0.598s151.06x
🐘 PostgresNext.js (Turbopack)1.901s (+3.1%)2.164s (+4.7%)0.263s141.43x
🐘 PostgresExpress1.939s (-18.7% 🟢)2.225s (-26.1% 🟢)0.287s141.45x
🌐 MongoDBNext.js (Turbopack)2.130s (~)3.011s (~)0.881s101.60x
🌐 TursoNext.js (Turbopack)2.198s (~)3.014s (~)0.815s101.65x
🐘 PostgresNitro2.351s (~)2.921s (-3.0%)0.569s111.76x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.844s (+4.8%)3.777s (-0.9%)0.933s81.00x
▲ VercelNext.js (Turbopack)2.994s (-1.9%)3.767s (-5.3% 🟢)0.774s81.05x
▲ VercelExpress3.634s (+32.8% 🔺)4.564s (+29.8% 🔺)0.930s71.28x

🔍 Observability: Nitro | Next.js (Turbopack) | Express

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)2.081s (-3.8%)2.911s (-6.6% 🟢)0.830s111.00x
💻 LocalExpress2.206s (~)3.160s (~)0.954s101.06x
💻 LocalNitro2.224s (~)3.169s (~)0.945s101.07x
🌐 StarterNext.js (Turbopack)2.448s (~)3.009s (~)0.560s101.18x
🌐 RedisNext.js (Turbopack)2.506s (+0.5%)3.012s (~)0.507s101.20x
🐘 PostgresExpress2.704s (-7.4% 🟢)3.016s (-6.8% 🟢)0.312s101.30x
🐘 PostgresNitro2.713s (+2.2%)3.012s (-0.5%)0.298s101.30x
🐘 PostgresNext.js (Turbopack)2.920s (+10.9% 🔺)3.411s (+12.5% 🔺)0.491s91.40x
🌐 MongoDBNext.js (Turbopack)4.685s (-1.8%)5.180s (~)0.495s62.25x
🌐 TursoNext.js (Turbopack)4.704s (+0.7%)5.178s (~)0.474s62.26x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.558s (+16.9% 🔺)4.236s (+15.0% 🔺)0.678s81.00x
▲ VercelNext.js (Turbopack)3.664s (+15.3% 🔺)4.434s (+16.2% 🔺)0.770s71.03x
▲ VercelNitro3.779s (+21.7% 🔺)4.309s (+12.4% 🔺)0.530s91.06x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Starter🥇 Next.js (Turbopack)1.350s (-1.6%)2.007s (~)0.656s151.00x
🌐 RedisNext.js (Turbopack)1.371s (-1.1%)2.009s (~)0.638s151.02x
💻 LocalNext.js (Turbopack)1.388s (-0.7%)2.013s (~)0.625s151.03x
💻 LocalExpress1.408s (~)2.006s (~)0.598s151.04x
💻 LocalNitro1.431s (~)2.005s (~)0.574s151.06x
🐘 PostgresExpress1.706s (-3.4%)2.009s (~)0.303s151.26x
🐘 PostgresNitro1.775s (-5.3% 🟢)2.011s (~)0.236s151.31x
🐘 PostgresNext.js (Turbopack)2.026s (+24.5% 🔺)2.231s (+10.8% 🔺)0.206s141.50x
🌐 MongoDBNext.js (Turbopack)2.176s (+2.7%)3.011s (~)0.835s101.61x
🌐 TursoNext.js (Turbopack)2.233s (+0.7%)3.015s (~)0.782s101.65x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.872s (+7.4% 🔺)3.757s (~)0.885s81.00x
▲ VercelExpress2.879s (+6.2% 🔺)4.136s (+17.9% 🔺)1.257s81.00x
▲ VercelNext.js (Turbopack)2.974s (+11.8% 🔺)4.393s (+20.0% 🔺)1.419s71.04x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)2.056s (-10.4% 🟢)2.916s (-10.1% 🟢)0.860s111.00x
💻 LocalExpress2.221s (-1.0%)3.152s (-1.1%)0.931s101.08x
💻 LocalNitro2.249s (-5.9% 🟢)3.178s (-4.6%)0.929s101.09x
🌐 StarterNext.js (Turbopack)2.458s (~)3.010s (~)0.552s101.20x
🐘 PostgresExpress2.502s (-8.1% 🟢)3.017s (~)0.515s101.22x
🌐 RedisNext.js (Turbopack)2.508s (+0.8%)3.012s (~)0.504s101.22x
🐘 PostgresNext.js (Turbopack)2.734s (+3.0%)3.028s (~)0.294s101.33x
🐘 PostgresNitro2.791s (+19.2% 🔺)3.038s (+0.9%)0.246s101.36x
🌐 TursoNext.js (Turbopack)4.693s (-1.6%)5.181s (~)0.488s62.28x
🌐 MongoDBNext.js (Turbopack)4.748s (+0.9%)5.179s (~)0.431s62.31x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.181s (+7.2% 🔺)3.741s (+1.6%)0.559s91.00x
▲ VercelExpress3.352s (+4.0%)3.953s (+3.9%)0.601s81.05x
▲ VercelNext.js (Turbopack)3.540s (+10.7% 🔺)4.341s (+11.9% 🔺)0.800s71.11x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🌐 Starter🥇 Next.js (Turbopack)0.125s (-3.2%)1.005s (~)0.000s (+Infinity% 🔺)1.010s (~)0.885s101.00x
💻 LocalNext.js (Turbopack)0.135s (-8.2% 🟢)1.003s (~)0.019s (+14.1% 🔺)1.029s (~)0.895s101.08x
🌐 RedisNext.js (Turbopack)0.141s (-1.1%)1.005s (~)0.000s (+Infinity% 🔺)1.013s (~)0.872s101.13x
💻 LocalNitro0.176s (~)0.992s (~)0.015s (-6.9% 🟢)1.021s (~)0.845s101.41x
💻 LocalExpress0.176s (~)0.992s (~)0.016s (~)1.022s (~)0.846s101.41x
🌐 TursoNext.js (Turbopack)0.461s (-5.9% 🟢)0.985s (+2.5%)0.000s (~)1.012s (~)0.551s103.69x
🌐 MongoDBNext.js (Turbopack)0.523s (+1.9%)0.925s (-1.1%)0.000s (+Infinity% 🔺)1.013s (~)0.490s104.18x
🐘 PostgresExpress1.353s (-41.6% 🟢)1.687s (-38.1% 🟢)0.000s (+200.0% 🔺)2.012s (-33.3% 🟢)0.659s1010.81x
🐘 PostgresNitro1.370s (-39.7% 🟢)1.670s (-39.7% 🟢)0.000s (-100.0% 🟢)2.012s (-33.2% 🟢)0.642s1010.95x
🐘 PostgresNext.js (Turbopack)1.608s (+35.8% 🔺)2.296s (+34.4% 🔺)0.000s (+Infinity% 🔺)2.418s (+32.9% 🔺)0.811s1012.85x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.631s (-11.4% 🟢)3.110s (-3.9%)0.657s (-8.1% 🟢)4.223s (-6.0% 🟢)1.592s101.00x
▲ VercelNitro2.770s (+4.7%)3.322s (+3.1%)0.567s (-15.4% 🟢)4.412s (+2.5%)1.643s101.05x
▲ VercelNext.js (Turbopack)2.788s (+6.0% 🔺)3.253s (+1.8%)0.832s (+45.0% 🔺)4.510s (+7.5% 🔺)1.722s101.06x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNext.js (Turbopack)8/8
🐘 PostgresExpress5/8
▲ VercelNitro4/8
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express💻 Local8/8
Next.js (Turbopack)🌐 Starter6/8
Nitro💻 Local8/8
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Starter: Community world (local development)
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)

📋 View full workflow run

@github-actions

github-actionsBot commented Dec 20, 2025

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production286011297
✅ 💻 Local Development26208270
✅ 📦 Local Production26208270
✅ 🐘 Local Postgres26208270
✅ 🪟 Windows270027
❌ 🌍 Community Worlds109110120
Total120811351254

❌ Failed Tests

🌍 Community Worlds (11 failed)

mongodb (1 failed):

  • webhookWorkflow

redis (1 failed):

  • webhookWorkflow

starter (8 failed):

  • addTenWorkflow
  • addTenWorkflow
  • retryAttemptCounterWorkflow
  • crossFileErrorWorkflow - stack traces work across imported modules
  • hookCleanupTestWorkflow - hook token reuse after workflow completion
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step

turso (1 failed):

  • webhookWorkflow

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro2601
✅ example2601
✅ express2601
✅ fastify2601
✅ hono2601
✅ nextjs-turbopack2601
✅ nextjs-webpack2601
✅ nitro2601
✅ nuxt2601
✅ sveltekit2601
✅ vite2601
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable2601
✅ express-stable2601
✅ fastify-stable2601
✅ hono-stable2601
✅ nextjs-turbopack-stable2700
✅ nextjs-webpack-stable2700
✅ nitro-stable2601
✅ nuxt-stable2601
✅ sveltekit-stable2601
✅ vite-stable2601
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable2601
✅ express-stable2601
✅ fastify-stable2601
✅ hono-stable2601
✅ nextjs-turbopack-stable2700
✅ nextjs-webpack-stable2700
✅ nitro-stable2601
✅ nuxt-stable2601
✅ sveltekit-stable2601
✅ vite-stable2601
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable2601
✅ express-stable2601
✅ fastify-stable2601
✅ hono-stable2601
✅ nextjs-turbopack-stable2700
✅ nextjs-webpack-stable2700
✅ nitro-stable2601
✅ nuxt-stable2601
✅ sveltekit-stable2601
✅ vite-stable2601
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack2700
❌ 🌍 Community Worlds
AppPassedFailedSkipped
✅ mongodb-dev300
❌ mongodb2610
✅ redis-dev300
❌ redis2610
✅ starter-dev300
❌ starter1980
✅ turso-dev300
❌ turso2610

📋 View full workflow run

@vercel

vercelBot commented Dec 20, 2025

Copy link
Copy Markdown
Contributor

@TooTallNateGraphite App

TooTallNate commented Dec 20, 2025

Copy link
Copy Markdown
MemberAuthor

Comment on lines +356 to +362
} catch (err) {
state.streamEnded = true;
if (!state.doneResolved) {
state.doneResolved = true;
state.reject(err);
}
throw err;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stream locks are not released when an error occurs in the pipe. The error is thrown without releasing the locks acquired at the start of the function.

View Details
📝 Patch Details
diff --git a/packages/core/src/serialization.ts b/packages/core/src/serialization.ts
index c8ebc73..23e2ae6 100644
--- a/packages/core/src/serialization.ts+++ b/packages/core/src/serialization.ts@@ -359,6 +359,8 @@ async function flushablePipe(
state.doneResolved = true;
state.reject(err);
}
+ reader.releaseLock();+ writer.releaseLock();
throw err;
}
}

Analysis

Stream locks not released when error occurs in flushablePipe

What fails: The flushablePipe() function in packages/core/src/serialization.ts does not release the reader and writer locks when an error is thrown in the catch block (lines 356-362), leaving the streams in a permanently locked state.

How to reproduce: Create a test scenario where an error occurs during pipe operation:

// Create a readable streamconstsource=newReadableStream({start(controller){controller.enqueue('chunk1');controller.enqueue('chunk2');controller.close();}});// Create a writable stream that errors on second writeletwriteCount=0;constsink=newWritableStream({asyncwrite(chunk){writeCount++;if(writeCount===2){thrownewError('Write failed!');}}});// Call flushablePipe with error stateconststate={pendingOps: 0,doneResolved: false,streamEnded: false,resolve: ()=>{},reject: ()=>{}};try{awaitflushablePipe(source,sink,state);}catch(err){// Error caught}// Attempt to acquire new reader/writersource.getReader();// Throws: "InvalidStateError: ReadableStream is locked"sink.getWriter();// Throws: "InvalidStateError: WritableStream is locked"

Result: Streams remain locked (source.locked === true, sink.locked === true), preventing new readers/writers from being obtained. Cannot acquire new reader: "InvalidStateError: ReadableStream is locked". Cannot acquire new writer: "InvalidStateError: WritableStream is locked".

Expected behavior: The locks should be released before throwing the error, as they are in normal code paths (lines 326, 339, 346). Per WHATWG Streams API specification, a lock must be explicitly released, and a locked stream prevents other readers/writers from being obtained. Leaving locks unreleased creates a resource leak that prevents stream reuse.

Root cause: The catch block at lines 356-362 sets error state and throws without calling reader.releaseLock() and writer.releaseLock(), unlike all other exit paths from the function which properly release locks.

Fix applied: Added reader.releaseLock() and writer.releaseLock() before the throw err statement in the catch block to match the cleanup pattern used in normal code paths.

Comment on lines +329 to +338
if (readResult.done) {
// Source stream completed - close sink and resolve
state.streamEnded = true;
await writer.close();
// Resolve done promise if not already resolved
if (!state.doneResolved) {
state.doneResolved = true;
state.resolve();
}
return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stream locks are not released when the source stream ends. The flushablePipe function acquires locks on the reader and writer but doesn't release them before returning when the readable stream completes.

View Details
📝 Patch Details
diff --git a/packages/core/src/serialization.ts b/packages/core/src/serialization.ts
index c8ebc73..57e4750 100644
--- a/packages/core/src/serialization.ts+++ b/packages/core/src/serialization.ts@@ -335,6 +335,8 @@ async function flushablePipe(
state.doneResolved = true;
state.resolve();
}
+ reader.releaseLock();+ writer.releaseLock();
return;
}
@@ -359,6 +361,8 @@ async function flushablePipe(
state.doneResolved = true;
state.reject(err);
}
+ reader.releaseLock();+ writer.releaseLock();
throw err;
}
}

Analysis

Stream locks not released in flushablePipe() when source completes or errors

What fails: The flushablePipe() function in packages/core/src/serialization.ts (lines 333-341 and 365-371) acquires locks on ReadableStream and WritableStream but fails to release them before returning when the stream completes or when an error occurs.

How to reproduce:

// When readResult.done is true (source stream completed):constreadResult=awaitreader.read();if(readResult.done){state.streamEnded=true;awaitwriter.close();// ... resolve promise ...return;// ❌ Returns without releasing locks}// Or when error occurs:}catch(err){state.streamEnded=true;// ... reject promise ...throwerr;// ❌ Throws without releasing locks}

Result: Unreleased locks prevent any other code from acquiring new readers or writers on these streams. Subsequent attempts to call getReader() or getWriter() will throw TypeError: ReadableStreamDefaultReader constructor can only accept readable streams that are not yet locked to a reader. The stream becomes permanently inaccessible.

Expected behavior: According to MDN Web JavaScript resource management, unreleased locks cause the stream to remain locked indefinitely and "does not permit another reader to consume it." The locks must be explicitly released via reader.releaseLock() and writer.releaseLock() before the function exits.

Fix: Added reader.releaseLock() and writer.releaseLock() calls before all return/throw statements in the flushablePipe function:

  • Before returning when readResult.done (normal stream completion)
  • Before throwing in the catch block (error case)

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements a polling mechanism to detect when stream locks are released, allowing workflow steps to complete without waiting for streams to fully close. This fixes an issue where Vercel functions would hang when users write to streams and release locks without explicitly closing them.

Key Changes:

  • Added flushablePipe function that resolves when either the stream completes or when the user releases their lock with all pending writes flushed
  • Implemented polling functions (pollWritableLock and pollReadableLock) that check stream lock state every 100ms
  • Created state tracking system (FlushableStreamState) to monitor pending operations and resolution status

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 15 comments.

FileDescription
packages/core/src/serialization.tsImplements the core flushable pipe mechanism with polling-based lock detection and state tracking for both readable and writable streams
packages/core/src/serialization.test.tsAdds tests verifying the new behavior for both lock release and natural stream closure scenarios
.changeset/stream-lock-polling.mdDocuments the patch-level change for the @workflow/core package

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +1174 to +1176
flushablePipe(readable, transform.writable, state).catch(() => {
// Errors are handled via state.reject
});

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The flushablePipe function throws errors after calling state.reject() (line 405), but all call sites catch and ignore these thrown errors with empty catch blocks (e.g., line 976, 992, 1016). This pattern is confusing because the error is both rejected through the state AND thrown. Since the error is already propagated via state.reject() which rejects the 'done' promise, the re-throw serves no purpose and the empty catch blocks make this unclear. Consider either removing the throw from flushablePipe or documenting why the error needs to be both rejected and thrown.

Copilot uses AI. Check for mistakes.
Comment on lines +399 to +406
} catch (err) {
state.streamEnded = true;
if (!state.doneResolved) {
state.doneResolved = true;
state.reject(err);
}
throw err;
}

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error handling in the flushablePipe function could potentially leak resources. When an exception occurs in the catch block at line 399, the reader and writer locks are not released before the error is re-thrown. This could leave the streams in a locked state. Consider releasing the locks in the catch block before re-throwing.

Copilot uses AI. Check for mistakes.
Comment on lines +229 to +261
/**
* Checks if a WritableStream is unlocked (user released lock) vs closed.
* When a stream is closed, .locked is false but getWriter() throws.
* We only want to resolve via polling when the stream is unlocked, not closed.
* If closed, the pump will handle resolution via the stream ending naturally.
*/
function isWritableUnlockedNotClosed(writable: WritableStream): boolean {
if (writable.locked) return false;

try {
// Try to acquire writer - if successful, stream is unlocked (not closed)
const writer = writable.getWriter();
writer.releaseLock();
return true;
} catch {
// getWriter() throws if stream is closed/errored - let pump handle it
return false;
}
}

/**
* Checks if a ReadableStream is unlocked (user released lock) vs closed.
*/
function isReadableUnlockedNotClosed(readable: ReadableStream): boolean {
if (readable.locked) return false;

try {
// Try to acquire reader - if successful, stream is unlocked (not closed)
const reader = readable.getReader();
reader.releaseLock();
return true;
} catch {
// getReader() throws if stream is closed/errored - let pump handle it

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The polling functions acquire and release stream locks every 100ms to check if a stream is unlocked vs closed. This repeated lock acquisition could have performance implications and might interfere with stream operations. Consider using a different approach to detect stream closure, such as checking for specific error messages or using a flag that's set when the stream closes, to avoid repeatedly acquiring locks during polling.

Suggested change
/**
*ChecksifaWritableStreamisunlocked(userreleasedlock)vsclosed.
*Whenastreamisclosed,.lockedisfalsebutgetWriter()throws.
*Weonlywanttoresolveviapollingwhenthestreamisunlocked,notclosed.
*Ifclosed,thepumpwillhandleresolutionviathestreamendingnaturally.
*/
functionisWritableUnlockedNotClosed(writable: WritableStream): boolean{
if(writable.locked)returnfalse;
try{
// Try to acquire writer - if successful, stream is unlocked (not closed)
constwriter=writable.getWriter();
writer.releaseLock();
returntrue;
}catch{
// getWriter() throws if stream is closed/errored - let pump handle it
returnfalse;
}
}
/**
*ChecksifaReadableStreamisunlocked(userreleasedlock)vsclosed.
*/
functionisReadableUnlockedNotClosed(readable: ReadableStream): boolean{
if(readable.locked)returnfalse;
try{
// Try to acquire reader - if successful, stream is unlocked (not closed)
constreader=readable.getReader();
reader.releaseLock();
returntrue;
}catch{
// getReader() throws if stream is closed/errored - let pump handle it
/**
*Cacheofper-stream"unlocked but not closed"statusforWritableStreams.
*Thisavoidsrepeatedlyacquiringandreleasinglocksduringpolling;we
*probethestreamatmostonceperinstancewhenittransitionstounlocked.
*/
constwritableUnlockedNotClosedCache=newWeakMap<WritableStream,boolean>();
/**
*ChecksifaWritableStreamisunlocked(userreleasedlock)vsclosed.
*Whenastreamisclosed,.lockedisfalsebutgetWriter()throws.
*Weonlywanttoresolveviapollingwhenthestreamisunlocked,notclosed.
*Ifclosed,thepumpwillhandleresolutionviathestreamendingnaturally.
*
*Toavoidrepeatedlockacquisitionduringpolling,wecachetheresultof
*theunlocked-vs-closedprobeperstream.Oncedetermined,thestatus is
*reusedonsubsequentcallsforthesamestream.
*/
functionisWritableUnlockedNotClosed(writable: WritableStream): boolean{
if(writable.locked)returnfalse;
constcached=writableUnlockedNotClosedCache.get(writable);
if(cached!==undefined){
returncached;
}
try{
// Try to acquire writer - if successful, stream is unlocked (not closed)
constwriter=writable.getWriter();
writer.releaseLock();
writableUnlockedNotClosedCache.set(writable,true);
returntrue;
}catch{
// getWriter() throws if stream is closed/errored - let pump handle it
writableUnlockedNotClosedCache.set(writable,false);
returnfalse;
}
}
/**
*Cacheofper-stream"unlocked but not closed"statusforReadableStreams.
*Likethewritablecache,thispreventsrepeatedreaderacquisitionswhile
*pollingforlockrelease.
*/
constreadableUnlockedNotClosedCache=newWeakMap<ReadableStream,boolean>();
/**
*ChecksifaReadableStreamisunlocked(userreleasedlock)vsclosed.
*Usesaper-streamcachetoavoidrepeatedlyacquiringandreleasinglocks
*duringpolling.
*/
functionisReadableUnlockedNotClosed(readable: ReadableStream): boolean{
if(readable.locked)returnfalse;
constcached=readableUnlockedNotClosedCache.get(readable);
if(cached!==undefined){
returncached;
}
try{
// Try to acquire reader - if successful, stream is unlocked (not closed)
constreader=readable.getReader();
reader.releaseLock();
readableUnlockedNotClosedCache.set(readable,true);
returntrue;
}catch{
// getReader() throws if stream is closed/errored - let pump handle it
readableUnlockedNotClosedCache.set(readable,false);

Copilot uses AI. Check for mistakes.
Comment on lines +1179 to +1186
} catch (err) {
state.streamEnded = true;
if (!state.doneResolved) {
state.doneResolved = true;
state.reject(err as Error);
}
}
})();

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error handling in the test's pipe implementation (line 1179-1185) doesn't release the reader and writer locks before exiting, similar to the issue in the actual flushablePipe implementation. While this mirrors the production code, it would be better to release the locks in the catch block to properly clean up resources. Consider updating the test to match the corrected implementation once the production code is fixed.

Copilot uses AI. Check for mistakes.
Comment on lines +1016 to +1018
flushablePipe(serialize.readable, serverWritable, state).catch(() => {
// Errors are handled via state.reject
});

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The flushablePipe function throws errors after calling state.reject() (line 405), but all call sites catch and ignore these thrown errors with empty catch blocks (e.g., line 976, 992, 1016). This pattern is confusing because the error is both rejected through the state AND thrown. Since the error is already propagated via state.reject() which rejects the 'done' promise, the re-throw serves no purpose and the empty catch blocks make this unclear. Consider either removing the throw from flushablePipe or documenting why the error needs to be both rejected and thrown.

Suggested change
flushablePipe(serialize.readable,serverWritable,state).catch(()=>{
// Errors are handled via state.reject
});
flushablePipe(serialize.readable,serverWritable,state).catch(
(error)=>{
// flushablePipe already calls state.reject(error), which rejects the
// `done` promise added to `ops`. We catch and intentionally ignore
// this rejection here only to avoid an unhandled promise rejection;
// all real error handling flows through state.done.
}
);

Copilot uses AI. Check for mistakes.
Comment on lines +1001 to +1229
describe('flushable stream behavior', () => {
const POLL_INTERVAL = 100; // Match the actual implementation
const STABLE_POLL_COUNT = 2; // Match the actual implementation

it('done promise should resolve when writable stream lock is released (polling)', async () => {
// Test the pattern: user writes, releases lock, polling detects it, done resolves
const chunks: string[] = [];
let streamClosed = false;

// Create a simple mock for the sink
const mockSink = new WritableStream<string>({
write(chunk) {
chunks.push(chunk);
},
close() {
streamClosed = true;
},
});

// Create a TransformStream like we do in getStepRevivers
const { readable, writable } = new TransformStream<string, string>();

// Track flushable state - this mirrors the actual implementation
const state = {
pendingOps: 0, // Only counts writes to server
doneResolved: false,
streamEnded: false,
resolve: () => {},
reject: (_err: Error) => {},
};

const done = new Promise<void>((res, rej) => {
state.resolve = res;
state.reject = rej;
});

// Start piping in background (mirrors flushablePipe implementation)
(async () => {
const reader = readable.getReader();
const writer = mockSink.getWriter();
try {
while (!state.streamEnded) {
const result = await reader.read();

if (result.done) {
state.streamEnded = true;
await writer.close();
if (!state.doneResolved) {
state.doneResolved = true;
state.resolve();
}
return;
}

// Only writes count as pending ops
state.pendingOps++;
await writer.write(result.value);
state.pendingOps--;

if (state.streamEnded) {
reader.releaseLock();
writer.releaseLock();
return;
}
}
} catch (err) {
state.streamEnded = true;
if (!state.doneResolved) {
state.doneResolved = true;
state.reject(err as Error);
}
}
})();

// Start polling (mirrors pollWritableLock implementation)
let stableCount = 0;
const intervalId = setInterval(() => {
if (state.doneResolved || state.streamEnded) {
clearInterval(intervalId);
return;
}

// Check if lock is released by checking .locked property
if (!writable.locked && state.pendingOps === 0) {
stableCount++;
if (stableCount >= STABLE_POLL_COUNT) {
state.doneResolved = true;
state.resolve();
clearInterval(intervalId);
}
} else {
stableCount = 0;
}
}, POLL_INTERVAL);

// Simulate user interaction - write and release lock
const userWriter = writable.getWriter();
await userWriter.write('chunk1');
await userWriter.write('chunk2');

// Release lock without closing stream
userWriter.releaseLock();

// Wait for pipe to process + polling intervals (need STABLE_POLL_COUNT consecutive polls)
await new Promise((r) => setTimeout(r, 250));

// The done promise should resolve
await expect(
Promise.race([
done,
new Promise((_, r) => setTimeout(() => r(new Error('timeout')), 400)),
])
).resolves.toBeUndefined();

// Chunks should have been written
expect(chunks).toContain('chunk1');
expect(chunks).toContain('chunk2');

// Stream should NOT be closed (user only released lock)
expect(streamClosed).toBe(false);
});

it('done promise should resolve when writable stream closes naturally', async () => {
const chunks: string[] = [];
let streamClosed = false;

const mockSink = new WritableStream<string>({
write(chunk) {
chunks.push(chunk);
},
close() {
streamClosed = true;
},
});

const { readable, writable } = new TransformStream<string, string>();

const state = {
pendingOps: 0,
doneResolved: false,
streamEnded: false,
resolve: () => {},
reject: (_err: Error) => {},
};

const done = new Promise<void>((res, rej) => {
state.resolve = res;
state.reject = rej;
});

// Start piping in background
(async () => {
const reader = readable.getReader();
const writer = mockSink.getWriter();
try {
while (!state.streamEnded) {
const result = await reader.read();

if (result.done) {
state.streamEnded = true;
await writer.close();
if (!state.doneResolved) {
state.doneResolved = true;
state.resolve();
}
return;
}

state.pendingOps++;
await writer.write(result.value);
state.pendingOps--;

if (state.streamEnded) {
reader.releaseLock();
writer.releaseLock();
return;
}
}
} catch (err) {
state.streamEnded = true;
if (!state.doneResolved) {
state.doneResolved = true;
state.reject(err as Error);
}
}
})();

// Start polling (won't trigger since stream will close first)
let stableCount2 = 0;
const intervalId = setInterval(() => {
if (state.doneResolved || state.streamEnded) {
clearInterval(intervalId);
return;
}
if (!writable.locked && state.pendingOps === 0) {
stableCount2++;
if (stableCount2 >= STABLE_POLL_COUNT) {
state.doneResolved = true;
state.resolve();
clearInterval(intervalId);
}
} else {
stableCount2 = 0;
}
}, POLL_INTERVAL);

// User writes and then closes the stream
const userWriter = writable.getWriter();
await userWriter.write('data');
await userWriter.close();

// Wait a tick for the pipe to process
await new Promise((r) => setTimeout(r, 50));

// The done promise should resolve
await expect(
Promise.race([
done,
new Promise((_, r) => setTimeout(() => r(new Error('timeout')), 200)),
])
).resolves.toBeUndefined();

// Chunks should have been written
expect(chunks).toContain('data');

// Stream should be closed (user closed it)
expect(streamClosed).toBe(true);
});
});

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new flushable stream behavior tests only cover the happy paths (lock release and normal closure). Consider adding test cases for error scenarios such as: stream errors during write operations, reader errors, and what happens when the stream is aborted. This would help ensure the error handling in flushablePipe and the polling mechanisms work correctly under failure conditions.

Copilot uses AI. Check for mistakes.
Comment on lines +1158 to +1160
flushablePipe(readable, writable, state).catch(() => {
// Errors are handled via state.reject
});

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The flushablePipe function throws errors after calling state.reject() (line 405), but all call sites catch and ignore these thrown errors with empty catch blocks (e.g., line 976, 992, 1016). This pattern is confusing because the error is both rejected through the state AND thrown. Since the error is already propagated via state.reject() which rejects the 'done' promise, the re-throw serves no purpose and the empty catch blocks make this unclear. Consider either removing the throw from flushablePipe or documenting why the error needs to be both rejected and thrown.

Copilot uses AI. Check for mistakes.
Comment on lines +192 to +196
* - `doneResolved`: The `done` promise has been resolved (step can complete)
* - `streamEnded`: The underlying stream has actually closed/errored
*
* The pump continues running even after `doneResolved=true` to handle
* any future writes if the user acquires a new lock.

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment states that "The pump continues running even after doneResolved=true to handle any future writes if the user acquires a new lock." However, this design could be problematic. Once the done promise resolves (pushed to ops), the workflow step will complete. If the user then acquires a new lock and writes more data after the step has completed, it could lead to unexpected behavior where data is being written to a stream associated with an already-completed step. Consider whether this is the intended behavior or if the stream should be closed/locked after the done promise resolves.

Suggested change
*-`doneResolved`: The`done`promisehasbeenresolved(stepcancomplete)
*-`streamEnded`: Theunderlyingstreamhasactuallyclosed/errored
*
*Thepumpcontinuesrunningevenafter`doneResolved=true`tohandle
* any futurewritesiftheuseracquiresanewlock.
*-`doneResolved`: The`done`promisehasbeenresolved(stepcancomplete),
*andnofurtherwritesshouldbeacceptedforthisstream.
*-`streamEnded`: Theunderlyingstreamhasactuallyclosed/errored.
*
*Once`doneResolved`becomes`true`,thepumpshouldonlydrainany
*already-pendingoperationsandthenstop;itmustnothandlenewwrites
*fromsubsequentlyacquiredlocks.

Copilot uses AI. Check for mistakes.
Comment on lines +1066 to +1073
} catch (err) {
state.streamEnded = true;
if (!state.doneResolved) {
state.doneResolved = true;
state.reject(err as Error);
}
}
})();

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error handling in the test's pipe implementation (line 1066-1072) doesn't release the reader and writer locks before exiting, similar to the issue in the actual flushablePipe implementation. While this mirrors the production code, it would be better to release the locks in the catch block to properly clean up resources. Consider updating the test to match the corrected implementation once the production code is fixed.

Copilot uses AI. Check for mistakes.
Comment on lines +1189 to +1203
let stableCount2 = 0;
const intervalId = setInterval(() => {
if (state.doneResolved || state.streamEnded) {
clearInterval(intervalId);
return;
}
if (!writable.locked && state.pendingOps === 0) {
stableCount2++;
if (stableCount2 >= STABLE_POLL_COUNT) {
state.doneResolved = true;
state.resolve();
clearInterval(intervalId);
}
} else {
stableCount2 = 0;

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The variable name stableCount2 is unclear and suggests copy-paste from the first test. Consider renaming to stableCount for consistency, as the numbering doesn't add clarity and makes the code harder to read.

Suggested change
letstableCount2=0;
constintervalId=setInterval(()=>{
if(state.doneResolved||state.streamEnded){
clearInterval(intervalId);
return;
}
if(!writable.locked&&state.pendingOps===0){
stableCount2++;
if(stableCount2>=STABLE_POLL_COUNT){
state.doneResolved=true;
state.resolve();
clearInterval(intervalId);
}
}else{
stableCount2=0;
letstableCount=0;
constintervalId=setInterval(()=>{
if(state.doneResolved||state.streamEnded){
clearInterval(intervalId);
return;
}
if(!writable.locked&&state.pendingOps===0){
stableCount++;
if(stableCount>=STABLE_POLL_COUNT){
state.doneResolved=true;
state.resolve();
clearInterval(intervalId);
}
}else{
stableCount=0;

Copilot uses AI. Check for mistakes.

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before I review - there's a ton of AI comments on this PR and I want to give you a chance to resolve those, so I'm subscribing and re-reviewing after the next commit/message

Base automatically changed from 12-22-fix_race_conditions_in_webhook_response_handling to mainDecember 22, 2025 18:41
@TooTallNate

Copy link
Copy Markdown
MemberAuthor

@copilot Please evaluate the code review suggestions in this PR and apply them if you think they are relevent.

CopilotAI commented Dec 22, 2025

Copy link
Copy Markdown
Contributor

@TooTallNate I've opened a new pull request, #677, to work on those changes. Once the pull request is ready, I'll request review from you.

…f waiting for stream to close
This prevents Vercel functions from hanging when users incrementally write to streams within steps (e.g., `await writer.write(data); writer.releaseLock()`). Uses a polling approach to detect when the stream lock is released and all pending writes are flushed.
Comment on lines +271 to +316
function pollWritableLock(
writable: WritableStream,
state: FlushableStreamState
): void {
const intervalId = setInterval(() => {
// Stop polling if already resolved or stream ended
if (state.doneResolved || state.streamEnded) {
clearInterval(intervalId);
return;
}

// Check if lock is released (not closed) and no pending ops
if (isWritableUnlockedNotClosed(writable) && state.pendingOps === 0) {
state.doneResolved = true;
state.resolve();
clearInterval(intervalId);
}
}, LOCK_POLL_INTERVAL_MS);
}

/**
* Polls a ReadableStream to check if the user has released their lock.
* Resolves the done promise when lock is released and no pending ops remain.
*
* Note: Only resolves if stream is unlocked but NOT closed. If the user closes
* the stream, the pump will handle resolution via the stream ending naturally.
*/
function pollReadableLock(
readable: ReadableStream,
state: FlushableStreamState
): void {
const intervalId = setInterval(() => {
// Stop polling if already resolved or stream ended
if (state.doneResolved || state.streamEnded) {
clearInterval(intervalId);
return;
}

// Check if lock is released (not closed) and no pending ops
if (isReadableUnlockedNotClosed(readable) && state.pendingOps === 0) {
state.doneResolved = true;
state.resolve();
clearInterval(intervalId);
}
}, LOCK_POLL_INTERVAL_MS);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The polling functions don't implement the "multiple consecutive polls" behavior mentioned in the docstring, creating a potential race condition where the done promise resolves before the pump finishes writing queued data.

View Details
📝 Patch Details
diff --git a/packages/core/src/serialization.ts b/packages/core/src/serialization.ts
index 69c391b..376afe4 100644
--- a/packages/core/src/serialization.ts+++ b/packages/core/src/serialization.ts@@ -182,6 +182,9 @@ export class WorkflowServerWritableStream extends WritableStream<Uint8Array> {
/** Polling interval for checking stream lock state (in milliseconds) */
const LOCK_POLL_INTERVAL_MS = 100;
+/** Number of consecutive stable polls before resolving done promise */+const STABLE_POLL_COUNT = 2;+
/**
* State tracker for flushable stream operations.
* Resolves when either:
@@ -272,6 +275,7 @@ function pollWritableLock(
writable: WritableStream,
state: FlushableStreamState
): void {
+ let stableCount = 0;
const intervalId = setInterval(() => {
// Stop polling if already resolved or stream ended
if (state.doneResolved || state.streamEnded) {
@@ -281,16 +285,22 @@ function pollWritableLock(
// Check if lock is released (not closed) and no pending ops
if (isWritableUnlockedNotClosed(writable) && state.pendingOps === 0) {
- state.doneResolved = true;- state.resolve();- clearInterval(intervalId);+ stableCount++;+ if (stableCount >= STABLE_POLL_COUNT) {+ state.doneResolved = true;+ state.resolve();+ clearInterval(intervalId);+ }+ } else {+ stableCount = 0;
}
}, LOCK_POLL_INTERVAL_MS);
}
/**
* Polls a ReadableStream to check if the user has released their lock.
- * Resolves the done promise when lock is released and no pending ops remain.+ * Resolves the done promise when lock is released and no pending ops remain+ * for multiple consecutive polls (to avoid race conditions with the pump).
*
* Note: Only resolves if stream is unlocked but NOT closed. If the user closes
* the stream, the pump will handle resolution via the stream ending naturally.
@@ -299,6 +309,7 @@ function pollReadableLock(
readable: ReadableStream,
state: FlushableStreamState
): void {
+ let stableCount = 0;
const intervalId = setInterval(() => {
// Stop polling if already resolved or stream ended
if (state.doneResolved || state.streamEnded) {
@@ -308,9 +319,14 @@ function pollReadableLock(
// Check if lock is released (not closed) and no pending ops
if (isReadableUnlockedNotClosed(readable) && state.pendingOps === 0) {
- state.doneResolved = true;- state.resolve();- clearInterval(intervalId);+ stableCount++;+ if (stableCount >= STABLE_POLL_COUNT) {+ state.doneResolved = true;+ state.resolve();+ clearInterval(intervalId);+ }+ } else {+ stableCount = 0;
}
}, LOCK_POLL_INTERVAL_MS);
}

Analysis

Race condition in polling functions: done promise resolves before pump finishes flushing

What fails: The pollWritableLock() and pollReadableLock() functions in packages/core/src/serialization.ts resolve the done promise immediately when the stream lock is released and pendingOps === 0, without waiting for multiple consecutive stable polls. This creates a race condition where the done promise can resolve while the pump is still reading/writing data.

How to reproduce:

  1. A stream is being piped asynchronously via flushablePipe()
  2. User releases the lock on the stream (but doesn't close it)
  3. Pump is momentarily between reads (at await reader.read()) with pendingOps === 0
  4. Polling fires and sees: lock released + pendingOps === 0 → resolves done promise immediately
  5. Runtime awaits Promise.all(ops) which includes the done promise and proceeds
  6. If pump reads more data and tries to write, the step is already marked complete
  7. This violates the contract that all writes must be flushed before the step completes

Expected behavior: Per the docstring at lines 265-266 and 293-294, polling should only resolve after the condition is stable for "multiple consecutive polls (to avoid race conditions with the pump)." The test file at lines 1002-1003 and 1084-1090 confirms this behavior by using STABLE_POLL_COUNT = 2 - only resolving after the unlock condition is seen 2 consecutive times.

Root cause: The actual implementation was missing the stability counter logic that the docstring promised and that the test expected. Added STABLE_POLL_COUNT constant and stability tracking to both polling functions.

Evidence:

  • Docstring promises: "for multiple consecutive polls (to avoid race conditions with the pump)"
  • Test implementation (lines 1084-1090): Implements stability counter that resets when condition changes
  • Test comment (line 1001): "mirrors the actual implementation"
  • Git history: Commit e8c3800 added polling with docstring but without stability logic

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@TooTallNate@VaguelySerious
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length \u003e 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Fix stream serialization to resolve when user releases lock instead of waiting for stream to close - #661

Closed
TooTallNate wants to merge 2 commits into
mainfrom
12-20-fix_stream_serialization_to_resolve_when_user_releases_lock_instead_of_waiting_for_stream_to_close
Closed

Fix stream serialization to resolve when user releases lock instead of waiting for stream to close#661
TooTallNate wants to merge 2 commits into
mainfrom
12-20-fix_stream_serialization_to_resolve_when_user_releases_lock_instead_of_waiting_for_stream_to_close

Conversation

@TooTallNate

@TooTallNateTooTallNate commented Dec 20, 2025

Copy link
Copy Markdown
Member

Fix stream serialization to resolve when users release locks instead of waiting for streams to close, preventing Vercel functions from hanging.

What changed?

  • Implemented a polling mechanism to detect when stream locks are released
  • Added flushablePipe function that resolves in two scenarios:
    1. When the stream completes normally (close/error)
    2. When the user releases their lock AND all pending writes are flushed
  • Created a state tracking system to monitor pending operations and lock status

How to test?

  1. Create a workflow step that incrementally writes to a stream:

    constwriter=stream.getWriter();awaitwriter.write(data1);writer.releaseLock();// Step should complete here without waiting
  2. Verify the step completes immediately after lock release rather than hanging

  3. Run the new test cases that verify both lock release and normal stream closure behaviors

Why make this change?

This fixes an issue where Vercel functions would hang when users incrementally write to streams within steps. Previously, the system would wait for the stream to fully close before resolving, but many users follow a pattern where they write data and release the lock without explicitly closing the stream. This change allows steps to complete as soon as the user releases the lock and all pending writes are flushed, which is the expected behavior in most streaming scenarios.

@changeset-bot

changeset-botBot commented Dec 20, 2025

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 579162a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 12 packages
NameType
@workflow/corePatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/web-sharedPatch
workflowPatch
@workflow/astroPatch
@workflow/sveltekitPatch
@workflow/world-testingPatch
@workflow/nuxtPatch
@workflow/aiPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actionsBot commented Dec 20, 2025

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Starter🥇 Next.js (Turbopack)0.039s (+0.5%)1.013s (~)0.974s101.00x
💻 LocalNext.js (Turbopack)0.040s (-2.0%)1.017s (~)0.977s101.03x
💻 LocalNitro0.042s (-0.7%)1.007s (~)0.965s101.08x
🌐 RedisNext.js (Turbopack)0.042s (+0.7%)1.017s (~)0.975s101.08x
💻 LocalExpress0.043s (~)1.008s (~)0.965s101.11x
🌐 TursoNext.js (Turbopack)0.071s (-28.1% 🟢)1.014s (~)0.943s101.84x
🌐 MongoDBNext.js (Turbopack)0.107s (+44.9% 🔺)1.014s (~)0.907s102.76x
🐘 PostgresNext.js (Turbopack)0.128s (-21.4% 🟢)1.018s (~)0.890s103.31x
🐘 PostgresNitro0.343s (+13.9% 🔺)1.012s (~)0.669s108.87x
🐘 PostgresExpress0.369s (+16.2% 🔺)1.014s (~)0.645s109.54x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express0.552s (-9.8% 🟢)1.429s (-2.2%)0.877s101.00x
▲ VercelNext.js (Turbopack)0.556s (-12.4% 🟢)1.526s (-3.7%)0.970s101.01x
▲ VercelNitro0.569s (+8.1% 🔺)1.522s (-23.2% 🟢)0.953s101.03x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Starter🥇 Next.js (Turbopack)1.095s (+0.6%)2.009s (~)0.914s101.00x
💻 LocalNext.js (Turbopack)1.095s (~)2.012s (~)0.916s101.00x
🌐 RedisNext.js (Turbopack)1.097s (~)2.012s (~)0.915s101.00x
💻 LocalNitro1.110s (~)2.006s (~)0.896s101.01x
💻 LocalExpress1.112s (~)2.007s (~)0.895s101.02x
🌐 TursoNext.js (Turbopack)1.293s (-0.8%)2.012s (~)0.719s101.18x
🌐 MongoDBNext.js (Turbopack)1.309s (+0.8%)2.012s (~)0.703s101.20x
🐘 PostgresNitro2.196s (+2.5%)3.013s (~)0.817s102.01x
🐘 PostgresExpress2.207s (+1.9%)3.014s (~)0.807s102.02x
🐘 PostgresNext.js (Turbopack)2.458s (+27.9% 🔺)3.017s (+49.6% 🔺)0.559s102.25x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.619s (-2.6%)3.592s (-2.9%)0.973s101.00x
▲ VercelExpress2.626s (-1.6%)3.502s (-1.2%)0.876s101.00x
▲ VercelNitro2.669s (-2.4%)3.624s (-2.0%)0.955s101.02x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Starter🥇 Next.js (Turbopack)10.588s (~)11.013s (~)0.425s51.00x
💻 LocalNext.js (Turbopack)10.632s (~)11.016s (~)0.384s51.00x
🌐 RedisNext.js (Turbopack)10.678s (~)11.021s (~)0.342s51.01x
💻 LocalExpress10.781s (~)11.014s (~)0.234s51.02x
💻 LocalNitro10.787s (~)11.013s (~)0.226s51.02x
🌐 TursoNext.js (Turbopack)12.192s (~)13.020s (~)0.828s51.15x
🌐 MongoDBNext.js (Turbopack)12.197s (~)13.019s (~)0.822s51.15x
🐘 PostgresExpress16.081s (-21.2% 🟢)16.837s (-20.0% 🟢)0.756s51.52x
🐘 PostgresNitro18.950s (-6.9% 🟢)19.634s (-5.8% 🟢)0.683s51.79x
🐘 PostgresNext.js (Turbopack)20.287s (+33.3% 🔺)21.046s (+31.2% 🔺)0.760s51.92x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro21.714s (+0.7%)22.834s (+1.8%)1.120s51.00x
▲ VercelExpress21.984s (+1.6%)23.013s (+3.2%)1.029s51.01x
▲ VercelNext.js (Turbopack)22.185s (+1.8%)23.116s (+2.5%)0.932s51.02x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Starter🥇 Next.js (Turbopack)1.334s (~)2.007s (~)0.673s151.00x
🌐 RedisNext.js (Turbopack)1.365s (+0.6%)2.011s (~)0.646s151.02x
💻 LocalNext.js (Turbopack)1.373s (-1.6%)2.010s (~)0.636s151.03x
💻 LocalNitro1.408s (~)2.005s (~)0.597s151.06x
💻 LocalExpress1.408s (~)2.006s (~)0.598s151.06x
🐘 PostgresNext.js (Turbopack)1.901s (+3.1%)2.164s (+4.7%)0.263s141.43x
🐘 PostgresExpress1.939s (-18.7% 🟢)2.225s (-26.1% 🟢)0.287s141.45x
🌐 MongoDBNext.js (Turbopack)2.130s (~)3.011s (~)0.881s101.60x
🌐 TursoNext.js (Turbopack)2.198s (~)3.014s (~)0.815s101.65x
🐘 PostgresNitro2.351s (~)2.921s (-3.0%)0.569s111.76x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.844s (+4.8%)3.777s (-0.9%)0.933s81.00x
▲ VercelNext.js (Turbopack)2.994s (-1.9%)3.767s (-5.3% 🟢)0.774s81.05x
▲ VercelExpress3.634s (+32.8% 🔺)4.564s (+29.8% 🔺)0.930s71.28x

🔍 Observability: Nitro | Next.js (Turbopack) | Express

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)2.081s (-3.8%)2.911s (-6.6% 🟢)0.830s111.00x
💻 LocalExpress2.206s (~)3.160s (~)0.954s101.06x
💻 LocalNitro2.224s (~)3.169s (~)0.945s101.07x
🌐 StarterNext.js (Turbopack)2.448s (~)3.009s (~)0.560s101.18x
🌐 RedisNext.js (Turbopack)2.506s (+0.5%)3.012s (~)0.507s101.20x
🐘 PostgresExpress2.704s (-7.4% 🟢)3.016s (-6.8% 🟢)0.312s101.30x
🐘 PostgresNitro2.713s (+2.2%)3.012s (-0.5%)0.298s101.30x
🐘 PostgresNext.js (Turbopack)2.920s (+10.9% 🔺)3.411s (+12.5% 🔺)0.491s91.40x
🌐 MongoDBNext.js (Turbopack)4.685s (-1.8%)5.180s (~)0.495s62.25x
🌐 TursoNext.js (Turbopack)4.704s (+0.7%)5.178s (~)0.474s62.26x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.558s (+16.9% 🔺)4.236s (+15.0% 🔺)0.678s81.00x
▲ VercelNext.js (Turbopack)3.664s (+15.3% 🔺)4.434s (+16.2% 🔺)0.770s71.03x
▲ VercelNitro3.779s (+21.7% 🔺)4.309s (+12.4% 🔺)0.530s91.06x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Starter🥇 Next.js (Turbopack)1.350s (-1.6%)2.007s (~)0.656s151.00x
🌐 RedisNext.js (Turbopack)1.371s (-1.1%)2.009s (~)0.638s151.02x
💻 LocalNext.js (Turbopack)1.388s (-0.7%)2.013s (~)0.625s151.03x
💻 LocalExpress1.408s (~)2.006s (~)0.598s151.04x
💻 LocalNitro1.431s (~)2.005s (~)0.574s151.06x
🐘 PostgresExpress1.706s (-3.4%)2.009s (~)0.303s151.26x
🐘 PostgresNitro1.775s (-5.3% 🟢)2.011s (~)0.236s151.31x
🐘 PostgresNext.js (Turbopack)2.026s (+24.5% 🔺)2.231s (+10.8% 🔺)0.206s141.50x
🌐 MongoDBNext.js (Turbopack)2.176s (+2.7%)3.011s (~)0.835s101.61x
🌐 TursoNext.js (Turbopack)2.233s (+0.7%)3.015s (~)0.782s101.65x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.872s (+7.4% 🔺)3.757s (~)0.885s81.00x
▲ VercelExpress2.879s (+6.2% 🔺)4.136s (+17.9% 🔺)1.257s81.00x
▲ VercelNext.js (Turbopack)2.974s (+11.8% 🔺)4.393s (+20.0% 🔺)1.419s71.04x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)2.056s (-10.4% 🟢)2.916s (-10.1% 🟢)0.860s111.00x
💻 LocalExpress2.221s (-1.0%)3.152s (-1.1%)0.931s101.08x
💻 LocalNitro2.249s (-5.9% 🟢)3.178s (-4.6%)0.929s101.09x
🌐 StarterNext.js (Turbopack)2.458s (~)3.010s (~)0.552s101.20x
🐘 PostgresExpress2.502s (-8.1% 🟢)3.017s (~)0.515s101.22x
🌐 RedisNext.js (Turbopack)2.508s (+0.8%)3.012s (~)0.504s101.22x
🐘 PostgresNext.js (Turbopack)2.734s (+3.0%)3.028s (~)0.294s101.33x
🐘 PostgresNitro2.791s (+19.2% 🔺)3.038s (+0.9%)0.246s101.36x
🌐 TursoNext.js (Turbopack)4.693s (-1.6%)5.181s (~)0.488s62.28x
🌐 MongoDBNext.js (Turbopack)4.748s (+0.9%)5.179s (~)0.431s62.31x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.181s (+7.2% 🔺)3.741s (+1.6%)0.559s91.00x
▲ VercelExpress3.352s (+4.0%)3.953s (+3.9%)0.601s81.05x
▲ VercelNext.js (Turbopack)3.540s (+10.7% 🔺)4.341s (+11.9% 🔺)0.800s71.11x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🌐 Starter🥇 Next.js (Turbopack)0.125s (-3.2%)1.005s (~)0.000s (+Infinity% 🔺)1.010s (~)0.885s101.00x
💻 LocalNext.js (Turbopack)0.135s (-8.2% 🟢)1.003s (~)0.019s (+14.1% 🔺)1.029s (~)0.895s101.08x
🌐 RedisNext.js (Turbopack)0.141s (-1.1%)1.005s (~)0.000s (+Infinity% 🔺)1.013s (~)0.872s101.13x
💻 LocalNitro0.176s (~)0.992s (~)0.015s (-6.9% 🟢)1.021s (~)0.845s101.41x
💻 LocalExpress0.176s (~)0.992s (~)0.016s (~)1.022s (~)0.846s101.41x
🌐 TursoNext.js (Turbopack)0.461s (-5.9% 🟢)0.985s (+2.5%)0.000s (~)1.012s (~)0.551s103.69x
🌐 MongoDBNext.js (Turbopack)0.523s (+1.9%)0.925s (-1.1%)0.000s (+Infinity% 🔺)1.013s (~)0.490s104.18x
🐘 PostgresExpress1.353s (-41.6% 🟢)1.687s (-38.1% 🟢)0.000s (+200.0% 🔺)2.012s (-33.3% 🟢)0.659s1010.81x
🐘 PostgresNitro1.370s (-39.7% 🟢)1.670s (-39.7% 🟢)0.000s (-100.0% 🟢)2.012s (-33.2% 🟢)0.642s1010.95x
🐘 PostgresNext.js (Turbopack)1.608s (+35.8% 🔺)2.296s (+34.4% 🔺)0.000s (+Infinity% 🔺)2.418s (+32.9% 🔺)0.811s1012.85x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.631s (-11.4% 🟢)3.110s (-3.9%)0.657s (-8.1% 🟢)4.223s (-6.0% 🟢)1.592s101.00x
▲ VercelNitro2.770s (+4.7%)3.322s (+3.1%)0.567s (-15.4% 🟢)4.412s (+2.5%)1.643s101.05x
▲ VercelNext.js (Turbopack)2.788s (+6.0% 🔺)3.253s (+1.8%)0.832s (+45.0% 🔺)4.510s (+7.5% 🔺)1.722s101.06x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNext.js (Turbopack)8/8
🐘 PostgresExpress5/8
▲ VercelNitro4/8
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express💻 Local8/8
Next.js (Turbopack)🌐 Starter6/8
Nitro💻 Local8/8
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Starter: Community world (local development)
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)

📋 View full workflow run

@github-actions

github-actionsBot commented Dec 20, 2025

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production286011297
✅ 💻 Local Development26208270
✅ 📦 Local Production26208270
✅ 🐘 Local Postgres26208270
✅ 🪟 Windows270027
❌ 🌍 Community Worlds109110120
Total120811351254

❌ Failed Tests

🌍 Community Worlds (11 failed)

mongodb (1 failed):

  • webhookWorkflow

redis (1 failed):

  • webhookWorkflow

starter (8 failed):

  • addTenWorkflow
  • addTenWorkflow
  • retryAttemptCounterWorkflow
  • crossFileErrorWorkflow - stack traces work across imported modules
  • hookCleanupTestWorkflow - hook token reuse after workflow completion
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step

turso (1 failed):

  • webhookWorkflow

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro2601
✅ example2601
✅ express2601
✅ fastify2601
✅ hono2601
✅ nextjs-turbopack2601
✅ nextjs-webpack2601
✅ nitro2601
✅ nuxt2601
✅ sveltekit2601
✅ vite2601
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable2601
✅ express-stable2601
✅ fastify-stable2601
✅ hono-stable2601
✅ nextjs-turbopack-stable2700
✅ nextjs-webpack-stable2700
✅ nitro-stable2601
✅ nuxt-stable2601
✅ sveltekit-stable2601
✅ vite-stable2601
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable2601
✅ express-stable2601
✅ fastify-stable2601
✅ hono-stable2601
✅ nextjs-turbopack-stable2700
✅ nextjs-webpack-stable2700
✅ nitro-stable2601
✅ nuxt-stable2601
✅ sveltekit-stable2601
✅ vite-stable2601
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable2601
✅ express-stable2601
✅ fastify-stable2601
✅ hono-stable2601
✅ nextjs-turbopack-stable2700
✅ nextjs-webpack-stable2700
✅ nitro-stable2601
✅ nuxt-stable2601
✅ sveltekit-stable2601
✅ vite-stable2601
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack2700
❌ 🌍 Community Worlds
AppPassedFailedSkipped
✅ mongodb-dev300
❌ mongodb2610
✅ redis-dev300
❌ redis2610
✅ starter-dev300
❌ starter1980
✅ turso-dev300
❌ turso2610

📋 View full workflow run

@vercel

vercelBot commented Dec 20, 2025

Copy link
Copy Markdown
Contributor

@TooTallNateGraphite App

TooTallNate commented Dec 20, 2025

Copy link
Copy Markdown
MemberAuthor

Comment on lines +356 to +362
} catch (err) {
state.streamEnded = true;
if (!state.doneResolved) {
state.doneResolved = true;
state.reject(err);
}
throw err;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stream locks are not released when an error occurs in the pipe. The error is thrown without releasing the locks acquired at the start of the function.

View Details
📝 Patch Details
diff --git a/packages/core/src/serialization.ts b/packages/core/src/serialization.ts
index c8ebc73..23e2ae6 100644
--- a/packages/core/src/serialization.ts+++ b/packages/core/src/serialization.ts@@ -359,6 +359,8 @@ async function flushablePipe(
state.doneResolved = true;
state.reject(err);
}
+ reader.releaseLock();+ writer.releaseLock();
throw err;
}
}

Analysis

Stream locks not released when error occurs in flushablePipe

What fails: The flushablePipe() function in packages/core/src/serialization.ts does not release the reader and writer locks when an error is thrown in the catch block (lines 356-362), leaving the streams in a permanently locked state.

How to reproduce: Create a test scenario where an error occurs during pipe operation:

// Create a readable streamconstsource=newReadableStream({start(controller){controller.enqueue('chunk1');controller.enqueue('chunk2');controller.close();}});// Create a writable stream that errors on second writeletwriteCount=0;constsink=newWritableStream({asyncwrite(chunk){writeCount++;if(writeCount===2){thrownewError('Write failed!');}}});// Call flushablePipe with error stateconststate={pendingOps: 0,doneResolved: false,streamEnded: false,resolve: ()=>{},reject: ()=>{}};try{awaitflushablePipe(source,sink,state);}catch(err){// Error caught}// Attempt to acquire new reader/writersource.getReader();// Throws: "InvalidStateError: ReadableStream is locked"sink.getWriter();// Throws: "InvalidStateError: WritableStream is locked"

Result: Streams remain locked (source.locked === true, sink.locked === true), preventing new readers/writers from being obtained. Cannot acquire new reader: "InvalidStateError: ReadableStream is locked". Cannot acquire new writer: "InvalidStateError: WritableStream is locked".

Expected behavior: The locks should be released before throwing the error, as they are in normal code paths (lines 326, 339, 346). Per WHATWG Streams API specification, a lock must be explicitly released, and a locked stream prevents other readers/writers from being obtained. Leaving locks unreleased creates a resource leak that prevents stream reuse.

Root cause: The catch block at lines 356-362 sets error state and throws without calling reader.releaseLock() and writer.releaseLock(), unlike all other exit paths from the function which properly release locks.

Fix applied: Added reader.releaseLock() and writer.releaseLock() before the throw err statement in the catch block to match the cleanup pattern used in normal code paths.

Comment on lines +329 to +338
if (readResult.done) {
// Source stream completed - close sink and resolve
state.streamEnded = true;
await writer.close();
// Resolve done promise if not already resolved
if (!state.doneResolved) {
state.doneResolved = true;
state.resolve();
}
return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stream locks are not released when the source stream ends. The flushablePipe function acquires locks on the reader and writer but doesn't release them before returning when the readable stream completes.

View Details
📝 Patch Details
diff --git a/packages/core/src/serialization.ts b/packages/core/src/serialization.ts
index c8ebc73..57e4750 100644
--- a/packages/core/src/serialization.ts+++ b/packages/core/src/serialization.ts@@ -335,6 +335,8 @@ async function flushablePipe(
state.doneResolved = true;
state.resolve();
}
+ reader.releaseLock();+ writer.releaseLock();
return;
}
@@ -359,6 +361,8 @@ async function flushablePipe(
state.doneResolved = true;
state.reject(err);
}
+ reader.releaseLock();+ writer.releaseLock();
throw err;
}
}

Analysis

Stream locks not released in flushablePipe() when source completes or errors

What fails: The flushablePipe() function in packages/core/src/serialization.ts (lines 333-341 and 365-371) acquires locks on ReadableStream and WritableStream but fails to release them before returning when the stream completes or when an error occurs.

How to reproduce:

// When readResult.done is true (source stream completed):constreadResult=awaitreader.read();if(readResult.done){state.streamEnded=true;awaitwriter.close();// ... resolve promise ...return;// ❌ Returns without releasing locks}// Or when error occurs:}catch(err){state.streamEnded=true;// ... reject promise ...throwerr;// ❌ Throws without releasing locks}

Result: Unreleased locks prevent any other code from acquiring new readers or writers on these streams. Subsequent attempts to call getReader() or getWriter() will throw TypeError: ReadableStreamDefaultReader constructor can only accept readable streams that are not yet locked to a reader. The stream becomes permanently inaccessible.

Expected behavior: According to MDN Web JavaScript resource management, unreleased locks cause the stream to remain locked indefinitely and "does not permit another reader to consume it." The locks must be explicitly released via reader.releaseLock() and writer.releaseLock() before the function exits.

Fix: Added reader.releaseLock() and writer.releaseLock() calls before all return/throw statements in the flushablePipe function:

  • Before returning when readResult.done (normal stream completion)
  • Before throwing in the catch block (error case)

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements a polling mechanism to detect when stream locks are released, allowing workflow steps to complete without waiting for streams to fully close. This fixes an issue where Vercel functions would hang when users write to streams and release locks without explicitly closing them.

Key Changes:

  • Added flushablePipe function that resolves when either the stream completes or when the user releases their lock with all pending writes flushed
  • Implemented polling functions (pollWritableLock and pollReadableLock) that check stream lock state every 100ms
  • Created state tracking system (FlushableStreamState) to monitor pending operations and resolution status

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 15 comments.

FileDescription
packages/core/src/serialization.tsImplements the core flushable pipe mechanism with polling-based lock detection and state tracking for both readable and writable streams
packages/core/src/serialization.test.tsAdds tests verifying the new behavior for both lock release and natural stream closure scenarios
.changeset/stream-lock-polling.mdDocuments the patch-level change for the @workflow/core package

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +1174 to +1176
flushablePipe(readable, transform.writable, state).catch(() => {
// Errors are handled via state.reject
});

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The flushablePipe function throws errors after calling state.reject() (line 405), but all call sites catch and ignore these thrown errors with empty catch blocks (e.g., line 976, 992, 1016). This pattern is confusing because the error is both rejected through the state AND thrown. Since the error is already propagated via state.reject() which rejects the 'done' promise, the re-throw serves no purpose and the empty catch blocks make this unclear. Consider either removing the throw from flushablePipe or documenting why the error needs to be both rejected and thrown.

Copilot uses AI. Check for mistakes.
Comment on lines +399 to +406
} catch (err) {
state.streamEnded = true;
if (!state.doneResolved) {
state.doneResolved = true;
state.reject(err);
}
throw err;
}

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error handling in the flushablePipe function could potentially leak resources. When an exception occurs in the catch block at line 399, the reader and writer locks are not released before the error is re-thrown. This could leave the streams in a locked state. Consider releasing the locks in the catch block before re-throwing.

Copilot uses AI. Check for mistakes.
Comment on lines +229 to +261
/**
* Checks if a WritableStream is unlocked (user released lock) vs closed.
* When a stream is closed, .locked is false but getWriter() throws.
* We only want to resolve via polling when the stream is unlocked, not closed.
* If closed, the pump will handle resolution via the stream ending naturally.
*/
function isWritableUnlockedNotClosed(writable: WritableStream): boolean {
if (writable.locked) return false;

try {
// Try to acquire writer - if successful, stream is unlocked (not closed)
const writer = writable.getWriter();
writer.releaseLock();
return true;
} catch {
// getWriter() throws if stream is closed/errored - let pump handle it
return false;
}
}

/**
* Checks if a ReadableStream is unlocked (user released lock) vs closed.
*/
function isReadableUnlockedNotClosed(readable: ReadableStream): boolean {
if (readable.locked) return false;

try {
// Try to acquire reader - if successful, stream is unlocked (not closed)
const reader = readable.getReader();
reader.releaseLock();
return true;
} catch {
// getReader() throws if stream is closed/errored - let pump handle it

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The polling functions acquire and release stream locks every 100ms to check if a stream is unlocked vs closed. This repeated lock acquisition could have performance implications and might interfere with stream operations. Consider using a different approach to detect stream closure, such as checking for specific error messages or using a flag that's set when the stream closes, to avoid repeatedly acquiring locks during polling.

Suggested change
/**
*ChecksifaWritableStreamisunlocked(userreleasedlock)vsclosed.
*Whenastreamisclosed,.lockedisfalsebutgetWriter()throws.
*Weonlywanttoresolveviapollingwhenthestreamisunlocked,notclosed.
*Ifclosed,thepumpwillhandleresolutionviathestreamendingnaturally.
*/
functionisWritableUnlockedNotClosed(writable: WritableStream): boolean{
if(writable.locked)returnfalse;
try{
// Try to acquire writer - if successful, stream is unlocked (not closed)
constwriter=writable.getWriter();
writer.releaseLock();
returntrue;
}catch{
// getWriter() throws if stream is closed/errored - let pump handle it
returnfalse;
}
}
/**
*ChecksifaReadableStreamisunlocked(userreleasedlock)vsclosed.
*/
functionisReadableUnlockedNotClosed(readable: ReadableStream): boolean{
if(readable.locked)returnfalse;
try{
// Try to acquire reader - if successful, stream is unlocked (not closed)
constreader=readable.getReader();
reader.releaseLock();
returntrue;
}catch{
// getReader() throws if stream is closed/errored - let pump handle it
/**
*Cacheofper-stream"unlocked but not closed"statusforWritableStreams.
*Thisavoidsrepeatedlyacquiringandreleasinglocksduringpolling;we
*probethestreamatmostonceperinstancewhenittransitionstounlocked.
*/
constwritableUnlockedNotClosedCache=newWeakMap<WritableStream,boolean>();
/**
*ChecksifaWritableStreamisunlocked(userreleasedlock)vsclosed.
*Whenastreamisclosed,.lockedisfalsebutgetWriter()throws.
*Weonlywanttoresolveviapollingwhenthestreamisunlocked,notclosed.
*Ifclosed,thepumpwillhandleresolutionviathestreamendingnaturally.
*
*Toavoidrepeatedlockacquisitionduringpolling,wecachetheresultof
*theunlocked-vs-closedprobeperstream.Oncedetermined,thestatus is
*reusedonsubsequentcallsforthesamestream.
*/
functionisWritableUnlockedNotClosed(writable: WritableStream): boolean{
if(writable.locked)returnfalse;
constcached=writableUnlockedNotClosedCache.get(writable);
if(cached!==undefined){
returncached;
}
try{
// Try to acquire writer - if successful, stream is unlocked (not closed)
constwriter=writable.getWriter();
writer.releaseLock();
writableUnlockedNotClosedCache.set(writable,true);
returntrue;
}catch{
// getWriter() throws if stream is closed/errored - let pump handle it
writableUnlockedNotClosedCache.set(writable,false);
returnfalse;
}
}
/**
*Cacheofper-stream"unlocked but not closed"statusforReadableStreams.
*Likethewritablecache,thispreventsrepeatedreaderacquisitionswhile
*pollingforlockrelease.
*/
constreadableUnlockedNotClosedCache=newWeakMap<ReadableStream,boolean>();
/**
*ChecksifaReadableStreamisunlocked(userreleasedlock)vsclosed.
*Usesaper-streamcachetoavoidrepeatedlyacquiringandreleasinglocks
*duringpolling.
*/
functionisReadableUnlockedNotClosed(readable: ReadableStream): boolean{
if(readable.locked)returnfalse;
constcached=readableUnlockedNotClosedCache.get(readable);
if(cached!==undefined){
returncached;
}
try{
// Try to acquire reader - if successful, stream is unlocked (not closed)
constreader=readable.getReader();
reader.releaseLock();
readableUnlockedNotClosedCache.set(readable,true);
returntrue;
}catch{
// getReader() throws if stream is closed/errored - let pump handle it
readableUnlockedNotClosedCache.set(readable,false);

Copilot uses AI. Check for mistakes.
Comment on lines +1179 to +1186
} catch (err) {
state.streamEnded = true;
if (!state.doneResolved) {
state.doneResolved = true;
state.reject(err as Error);
}
}
})();

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error handling in the test's pipe implementation (line 1179-1185) doesn't release the reader and writer locks before exiting, similar to the issue in the actual flushablePipe implementation. While this mirrors the production code, it would be better to release the locks in the catch block to properly clean up resources. Consider updating the test to match the corrected implementation once the production code is fixed.

Copilot uses AI. Check for mistakes.
Comment on lines +1016 to +1018
flushablePipe(serialize.readable, serverWritable, state).catch(() => {
// Errors are handled via state.reject
});

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The flushablePipe function throws errors after calling state.reject() (line 405), but all call sites catch and ignore these thrown errors with empty catch blocks (e.g., line 976, 992, 1016). This pattern is confusing because the error is both rejected through the state AND thrown. Since the error is already propagated via state.reject() which rejects the 'done' promise, the re-throw serves no purpose and the empty catch blocks make this unclear. Consider either removing the throw from flushablePipe or documenting why the error needs to be both rejected and thrown.

Suggested change
flushablePipe(serialize.readable,serverWritable,state).catch(()=>{
// Errors are handled via state.reject
});
flushablePipe(serialize.readable,serverWritable,state).catch(
(error)=>{
// flushablePipe already calls state.reject(error), which rejects the
// `done` promise added to `ops`. We catch and intentionally ignore
// this rejection here only to avoid an unhandled promise rejection;
// all real error handling flows through state.done.
}
);

Copilot uses AI. Check for mistakes.
Comment on lines +1001 to +1229
describe('flushable stream behavior', () => {
const POLL_INTERVAL = 100; // Match the actual implementation
const STABLE_POLL_COUNT = 2; // Match the actual implementation

it('done promise should resolve when writable stream lock is released (polling)', async () => {
// Test the pattern: user writes, releases lock, polling detects it, done resolves
const chunks: string[] = [];
let streamClosed = false;

// Create a simple mock for the sink
const mockSink = new WritableStream<string>({
write(chunk) {
chunks.push(chunk);
},
close() {
streamClosed = true;
},
});

// Create a TransformStream like we do in getStepRevivers
const { readable, writable } = new TransformStream<string, string>();

// Track flushable state - this mirrors the actual implementation
const state = {
pendingOps: 0, // Only counts writes to server
doneResolved: false,
streamEnded: false,
resolve: () => {},
reject: (_err: Error) => {},
};

const done = new Promise<void>((res, rej) => {
state.resolve = res;
state.reject = rej;
});

// Start piping in background (mirrors flushablePipe implementation)
(async () => {
const reader = readable.getReader();
const writer = mockSink.getWriter();
try {
while (!state.streamEnded) {
const result = await reader.read();

if (result.done) {
state.streamEnded = true;
await writer.close();
if (!state.doneResolved) {
state.doneResolved = true;
state.resolve();
}
return;
}

// Only writes count as pending ops
state.pendingOps++;
await writer.write(result.value);
state.pendingOps--;

if (state.streamEnded) {
reader.releaseLock();
writer.releaseLock();
return;
}
}
} catch (err) {
state.streamEnded = true;
if (!state.doneResolved) {
state.doneResolved = true;
state.reject(err as Error);
}
}
})();

// Start polling (mirrors pollWritableLock implementation)
let stableCount = 0;
const intervalId = setInterval(() => {
if (state.doneResolved || state.streamEnded) {
clearInterval(intervalId);
return;
}

// Check if lock is released by checking .locked property
if (!writable.locked && state.pendingOps === 0) {
stableCount++;
if (stableCount >= STABLE_POLL_COUNT) {
state.doneResolved = true;
state.resolve();
clearInterval(intervalId);
}
} else {
stableCount = 0;
}
}, POLL_INTERVAL);

// Simulate user interaction - write and release lock
const userWriter = writable.getWriter();
await userWriter.write('chunk1');
await userWriter.write('chunk2');

// Release lock without closing stream
userWriter.releaseLock();

// Wait for pipe to process + polling intervals (need STABLE_POLL_COUNT consecutive polls)
await new Promise((r) => setTimeout(r, 250));

// The done promise should resolve
await expect(
Promise.race([
done,
new Promise((_, r) => setTimeout(() => r(new Error('timeout')), 400)),
])
).resolves.toBeUndefined();

// Chunks should have been written
expect(chunks).toContain('chunk1');
expect(chunks).toContain('chunk2');

// Stream should NOT be closed (user only released lock)
expect(streamClosed).toBe(false);
});

it('done promise should resolve when writable stream closes naturally', async () => {
const chunks: string[] = [];
let streamClosed = false;

const mockSink = new WritableStream<string>({
write(chunk) {
chunks.push(chunk);
},
close() {
streamClosed = true;
},
});

const { readable, writable } = new TransformStream<string, string>();

const state = {
pendingOps: 0,
doneResolved: false,
streamEnded: false,
resolve: () => {},
reject: (_err: Error) => {},
};

const done = new Promise<void>((res, rej) => {
state.resolve = res;
state.reject = rej;
});

// Start piping in background
(async () => {
const reader = readable.getReader();
const writer = mockSink.getWriter();
try {
while (!state.streamEnded) {
const result = await reader.read();

if (result.done) {
state.streamEnded = true;
await writer.close();
if (!state.doneResolved) {
state.doneResolved = true;
state.resolve();
}
return;
}

state.pendingOps++;
await writer.write(result.value);
state.pendingOps--;

if (state.streamEnded) {
reader.releaseLock();
writer.releaseLock();
return;
}
}
} catch (err) {
state.streamEnded = true;
if (!state.doneResolved) {
state.doneResolved = true;
state.reject(err as Error);
}
}
})();

// Start polling (won't trigger since stream will close first)
let stableCount2 = 0;
const intervalId = setInterval(() => {
if (state.doneResolved || state.streamEnded) {
clearInterval(intervalId);
return;
}
if (!writable.locked && state.pendingOps === 0) {
stableCount2++;
if (stableCount2 >= STABLE_POLL_COUNT) {
state.doneResolved = true;
state.resolve();
clearInterval(intervalId);
}
} else {
stableCount2 = 0;
}
}, POLL_INTERVAL);

// User writes and then closes the stream
const userWriter = writable.getWriter();
await userWriter.write('data');
await userWriter.close();

// Wait a tick for the pipe to process
await new Promise((r) => setTimeout(r, 50));

// The done promise should resolve
await expect(
Promise.race([
done,
new Promise((_, r) => setTimeout(() => r(new Error('timeout')), 200)),
])
).resolves.toBeUndefined();

// Chunks should have been written
expect(chunks).toContain('data');

// Stream should be closed (user closed it)
expect(streamClosed).toBe(true);
});
});

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new flushable stream behavior tests only cover the happy paths (lock release and normal closure). Consider adding test cases for error scenarios such as: stream errors during write operations, reader errors, and what happens when the stream is aborted. This would help ensure the error handling in flushablePipe and the polling mechanisms work correctly under failure conditions.

Copilot uses AI. Check for mistakes.
Comment on lines +1158 to +1160
flushablePipe(readable, writable, state).catch(() => {
// Errors are handled via state.reject
});

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The flushablePipe function throws errors after calling state.reject() (line 405), but all call sites catch and ignore these thrown errors with empty catch blocks (e.g., line 976, 992, 1016). This pattern is confusing because the error is both rejected through the state AND thrown. Since the error is already propagated via state.reject() which rejects the 'done' promise, the re-throw serves no purpose and the empty catch blocks make this unclear. Consider either removing the throw from flushablePipe or documenting why the error needs to be both rejected and thrown.

Copilot uses AI. Check for mistakes.
Comment on lines +192 to +196
* - `doneResolved`: The `done` promise has been resolved (step can complete)
* - `streamEnded`: The underlying stream has actually closed/errored
*
* The pump continues running even after `doneResolved=true` to handle
* any future writes if the user acquires a new lock.

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment states that "The pump continues running even after doneResolved=true to handle any future writes if the user acquires a new lock." However, this design could be problematic. Once the done promise resolves (pushed to ops), the workflow step will complete. If the user then acquires a new lock and writes more data after the step has completed, it could lead to unexpected behavior where data is being written to a stream associated with an already-completed step. Consider whether this is the intended behavior or if the stream should be closed/locked after the done promise resolves.

Suggested change
*-`doneResolved`: The`done`promisehasbeenresolved(stepcancomplete)
*-`streamEnded`: Theunderlyingstreamhasactuallyclosed/errored
*
*Thepumpcontinuesrunningevenafter`doneResolved=true`tohandle
* any futurewritesiftheuseracquiresanewlock.
*-`doneResolved`: The`done`promisehasbeenresolved(stepcancomplete),
*andnofurtherwritesshouldbeacceptedforthisstream.
*-`streamEnded`: Theunderlyingstreamhasactuallyclosed/errored.
*
*Once`doneResolved`becomes`true`,thepumpshouldonlydrainany
*already-pendingoperationsandthenstop;itmustnothandlenewwrites
*fromsubsequentlyacquiredlocks.

Copilot uses AI. Check for mistakes.
Comment on lines +1066 to +1073
} catch (err) {
state.streamEnded = true;
if (!state.doneResolved) {
state.doneResolved = true;
state.reject(err as Error);
}
}
})();

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error handling in the test's pipe implementation (line 1066-1072) doesn't release the reader and writer locks before exiting, similar to the issue in the actual flushablePipe implementation. While this mirrors the production code, it would be better to release the locks in the catch block to properly clean up resources. Consider updating the test to match the corrected implementation once the production code is fixed.

Copilot uses AI. Check for mistakes.
Comment on lines +1189 to +1203
let stableCount2 = 0;
const intervalId = setInterval(() => {
if (state.doneResolved || state.streamEnded) {
clearInterval(intervalId);
return;
}
if (!writable.locked && state.pendingOps === 0) {
stableCount2++;
if (stableCount2 >= STABLE_POLL_COUNT) {
state.doneResolved = true;
state.resolve();
clearInterval(intervalId);
}
} else {
stableCount2 = 0;

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The variable name stableCount2 is unclear and suggests copy-paste from the first test. Consider renaming to stableCount for consistency, as the numbering doesn't add clarity and makes the code harder to read.

Suggested change
letstableCount2=0;
constintervalId=setInterval(()=>{
if(state.doneResolved||state.streamEnded){
clearInterval(intervalId);
return;
}
if(!writable.locked&&state.pendingOps===0){
stableCount2++;
if(stableCount2>=STABLE_POLL_COUNT){
state.doneResolved=true;
state.resolve();
clearInterval(intervalId);
}
}else{
stableCount2=0;
letstableCount=0;
constintervalId=setInterval(()=>{
if(state.doneResolved||state.streamEnded){
clearInterval(intervalId);
return;
}
if(!writable.locked&&state.pendingOps===0){
stableCount++;
if(stableCount>=STABLE_POLL_COUNT){
state.doneResolved=true;
state.resolve();
clearInterval(intervalId);
}
}else{
stableCount=0;

Copilot uses AI. Check for mistakes.

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before I review - there's a ton of AI comments on this PR and I want to give you a chance to resolve those, so I'm subscribing and re-reviewing after the next commit/message

Base automatically changed from 12-22-fix_race_conditions_in_webhook_response_handling to mainDecember 22, 2025 18:41
@TooTallNate

Copy link
Copy Markdown
MemberAuthor

@copilot Please evaluate the code review suggestions in this PR and apply them if you think they are relevent.

CopilotAI commented Dec 22, 2025

Copy link
Copy Markdown
Contributor

@TooTallNate I've opened a new pull request, #677, to work on those changes. Once the pull request is ready, I'll request review from you.

…f waiting for stream to close
This prevents Vercel functions from hanging when users incrementally write to streams within steps (e.g., `await writer.write(data); writer.releaseLock()`). Uses a polling approach to detect when the stream lock is released and all pending writes are flushed.
Comment on lines +271 to +316
function pollWritableLock(
writable: WritableStream,
state: FlushableStreamState
): void {
const intervalId = setInterval(() => {
// Stop polling if already resolved or stream ended
if (state.doneResolved || state.streamEnded) {
clearInterval(intervalId);
return;
}

// Check if lock is released (not closed) and no pending ops
if (isWritableUnlockedNotClosed(writable) && state.pendingOps === 0) {
state.doneResolved = true;
state.resolve();
clearInterval(intervalId);
}
}, LOCK_POLL_INTERVAL_MS);
}

/**
* Polls a ReadableStream to check if the user has released their lock.
* Resolves the done promise when lock is released and no pending ops remain.
*
* Note: Only resolves if stream is unlocked but NOT closed. If the user closes
* the stream, the pump will handle resolution via the stream ending naturally.
*/
function pollReadableLock(
readable: ReadableStream,
state: FlushableStreamState
): void {
const intervalId = setInterval(() => {
// Stop polling if already resolved or stream ended
if (state.doneResolved || state.streamEnded) {
clearInterval(intervalId);
return;
}

// Check if lock is released (not closed) and no pending ops
if (isReadableUnlockedNotClosed(readable) && state.pendingOps === 0) {
state.doneResolved = true;
state.resolve();
clearInterval(intervalId);
}
}, LOCK_POLL_INTERVAL_MS);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The polling functions don't implement the "multiple consecutive polls" behavior mentioned in the docstring, creating a potential race condition where the done promise resolves before the pump finishes writing queued data.

View Details
📝 Patch Details
diff --git a/packages/core/src/serialization.ts b/packages/core/src/serialization.ts
index 69c391b..376afe4 100644
--- a/packages/core/src/serialization.ts+++ b/packages/core/src/serialization.ts@@ -182,6 +182,9 @@ export class WorkflowServerWritableStream extends WritableStream<Uint8Array> {
/** Polling interval for checking stream lock state (in milliseconds) */
const LOCK_POLL_INTERVAL_MS = 100;
+/** Number of consecutive stable polls before resolving done promise */+const STABLE_POLL_COUNT = 2;+
/**
* State tracker for flushable stream operations.
* Resolves when either:
@@ -272,6 +275,7 @@ function pollWritableLock(
writable: WritableStream,
state: FlushableStreamState
): void {
+ let stableCount = 0;
const intervalId = setInterval(() => {
// Stop polling if already resolved or stream ended
if (state.doneResolved || state.streamEnded) {
@@ -281,16 +285,22 @@ function pollWritableLock(
// Check if lock is released (not closed) and no pending ops
if (isWritableUnlockedNotClosed(writable) && state.pendingOps === 0) {
- state.doneResolved = true;- state.resolve();- clearInterval(intervalId);+ stableCount++;+ if (stableCount >= STABLE_POLL_COUNT) {+ state.doneResolved = true;+ state.resolve();+ clearInterval(intervalId);+ }+ } else {+ stableCount = 0;
}
}, LOCK_POLL_INTERVAL_MS);
}
/**
* Polls a ReadableStream to check if the user has released their lock.
- * Resolves the done promise when lock is released and no pending ops remain.+ * Resolves the done promise when lock is released and no pending ops remain+ * for multiple consecutive polls (to avoid race conditions with the pump).
*
* Note: Only resolves if stream is unlocked but NOT closed. If the user closes
* the stream, the pump will handle resolution via the stream ending naturally.
@@ -299,6 +309,7 @@ function pollReadableLock(
readable: ReadableStream,
state: FlushableStreamState
): void {
+ let stableCount = 0;
const intervalId = setInterval(() => {
// Stop polling if already resolved or stream ended
if (state.doneResolved || state.streamEnded) {
@@ -308,9 +319,14 @@ function pollReadableLock(
// Check if lock is released (not closed) and no pending ops
if (isReadableUnlockedNotClosed(readable) && state.pendingOps === 0) {
- state.doneResolved = true;- state.resolve();- clearInterval(intervalId);+ stableCount++;+ if (stableCount >= STABLE_POLL_COUNT) {+ state.doneResolved = true;+ state.resolve();+ clearInterval(intervalId);+ }+ } else {+ stableCount = 0;
}
}, LOCK_POLL_INTERVAL_MS);
}

Analysis

Race condition in polling functions: done promise resolves before pump finishes flushing

What fails: The pollWritableLock() and pollReadableLock() functions in packages/core/src/serialization.ts resolve the done promise immediately when the stream lock is released and pendingOps === 0, without waiting for multiple consecutive stable polls. This creates a race condition where the done promise can resolve while the pump is still reading/writing data.

How to reproduce:

  1. A stream is being piped asynchronously via flushablePipe()
  2. User releases the lock on the stream (but doesn't close it)
  3. Pump is momentarily between reads (at await reader.read()) with pendingOps === 0
  4. Polling fires and sees: lock released + pendingOps === 0 → resolves done promise immediately
  5. Runtime awaits Promise.all(ops) which includes the done promise and proceeds
  6. If pump reads more data and tries to write, the step is already marked complete
  7. This violates the contract that all writes must be flushed before the step completes

Expected behavior: Per the docstring at lines 265-266 and 293-294, polling should only resolve after the condition is stable for "multiple consecutive polls (to avoid race conditions with the pump)." The test file at lines 1002-1003 and 1084-1090 confirms this behavior by using STABLE_POLL_COUNT = 2 - only resolving after the unlock condition is seen 2 consecutive times.

Root cause: The actual implementation was missing the stability counter logic that the docstring promised and that the test expected. Added STABLE_POLL_COUNT constant and stability tracking to both polling functions.

Evidence:

  • Docstring promises: "for multiple consecutive polls (to avoid race conditions with the pump)"
  • Test implementation (lines 1084-1090): Implements stability counter that resets when condition changes
  • Test comment (line 1001): "mirrors the actual implementation"
  • Git history: Commit e8c3800 added polling with docstring but without stability logic

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@TooTallNate@VaguelySerious
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Fix stream serialization to resolve when user releases lock instead of waiting for stream to close - #661

Closed
TooTallNate wants to merge 2 commits into
mainfrom
12-20-fix_stream_serialization_to_resolve_when_user_releases_lock_instead_of_waiting_for_stream_to_close
Closed

Fix stream serialization to resolve when user releases lock instead of waiting for stream to close#661
TooTallNate wants to merge 2 commits into
mainfrom
12-20-fix_stream_serialization_to_resolve_when_user_releases_lock_instead_of_waiting_for_stream_to_close

Conversation

@TooTallNate

@TooTallNateTooTallNate commented Dec 20, 2025

Copy link
Copy Markdown
Member

Fix stream serialization to resolve when users release locks instead of waiting for streams to close, preventing Vercel functions from hanging.

What changed?

  • Implemented a polling mechanism to detect when stream locks are released
  • Added flushablePipe function that resolves in two scenarios:
    1. When the stream completes normally (close/error)
    2. When the user releases their lock AND all pending writes are flushed
  • Created a state tracking system to monitor pending operations and lock status

How to test?

  1. Create a workflow step that incrementally writes to a stream:

    constwriter=stream.getWriter();awaitwriter.write(data1);writer.releaseLock();// Step should complete here without waiting
  2. Verify the step completes immediately after lock release rather than hanging

  3. Run the new test cases that verify both lock release and normal stream closure behaviors

Why make this change?

This fixes an issue where Vercel functions would hang when users incrementally write to streams within steps. Previously, the system would wait for the stream to fully close before resolving, but many users follow a pattern where they write data and release the lock without explicitly closing the stream. This change allows steps to complete as soon as the user releases the lock and all pending writes are flushed, which is the expected behavior in most streaming scenarios.

@changeset-bot

changeset-botBot commented Dec 20, 2025

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 579162a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 12 packages
NameType
@workflow/corePatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/web-sharedPatch
workflowPatch
@workflow/astroPatch
@workflow/sveltekitPatch
@workflow/world-testingPatch
@workflow/nuxtPatch
@workflow/aiPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actionsBot commented Dec 20, 2025

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Starter🥇 Next.js (Turbopack)0.039s (+0.5%)1.013s (~)0.974s101.00x
💻 LocalNext.js (Turbopack)0.040s (-2.0%)1.017s (~)0.977s101.03x
💻 LocalNitro0.042s (-0.7%)1.007s (~)0.965s101.08x
🌐 RedisNext.js (Turbopack)0.042s (+0.7%)1.017s (~)0.975s101.08x
💻 LocalExpress0.043s (~)1.008s (~)0.965s101.11x
🌐 TursoNext.js (Turbopack)0.071s (-28.1% 🟢)1.014s (~)0.943s101.84x
🌐 MongoDBNext.js (Turbopack)0.107s (+44.9% 🔺)1.014s (~)0.907s102.76x
🐘 PostgresNext.js (Turbopack)0.128s (-21.4% 🟢)1.018s (~)0.890s103.31x
🐘 PostgresNitro0.343s (+13.9% 🔺)1.012s (~)0.669s108.87x
🐘 PostgresExpress0.369s (+16.2% 🔺)1.014s (~)0.645s109.54x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express0.552s (-9.8% 🟢)1.429s (-2.2%)0.877s101.00x
▲ VercelNext.js (Turbopack)0.556s (-12.4% 🟢)1.526s (-3.7%)0.970s101.01x
▲ VercelNitro0.569s (+8.1% 🔺)1.522s (-23.2% 🟢)0.953s101.03x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Starter🥇 Next.js (Turbopack)1.095s (+0.6%)2.009s (~)0.914s101.00x
💻 LocalNext.js (Turbopack)1.095s (~)2.012s (~)0.916s101.00x
🌐 RedisNext.js (Turbopack)1.097s (~)2.012s (~)0.915s101.00x
💻 LocalNitro1.110s (~)2.006s (~)0.896s101.01x
💻 LocalExpress1.112s (~)2.007s (~)0.895s101.02x
🌐 TursoNext.js (Turbopack)1.293s (-0.8%)2.012s (~)0.719s101.18x
🌐 MongoDBNext.js (Turbopack)1.309s (+0.8%)2.012s (~)0.703s101.20x
🐘 PostgresNitro2.196s (+2.5%)3.013s (~)0.817s102.01x
🐘 PostgresExpress2.207s (+1.9%)3.014s (~)0.807s102.02x
🐘 PostgresNext.js (Turbopack)2.458s (+27.9% 🔺)3.017s (+49.6% 🔺)0.559s102.25x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.619s (-2.6%)3.592s (-2.9%)0.973s101.00x
▲ VercelExpress2.626s (-1.6%)3.502s (-1.2%)0.876s101.00x
▲ VercelNitro2.669s (-2.4%)3.624s (-2.0%)0.955s101.02x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Starter🥇 Next.js (Turbopack)10.588s (~)11.013s (~)0.425s51.00x
💻 LocalNext.js (Turbopack)10.632s (~)11.016s (~)0.384s51.00x
🌐 RedisNext.js (Turbopack)10.678s (~)11.021s (~)0.342s51.01x
💻 LocalExpress10.781s (~)11.014s (~)0.234s51.02x
💻 LocalNitro10.787s (~)11.013s (~)0.226s51.02x
🌐 TursoNext.js (Turbopack)12.192s (~)13.020s (~)0.828s51.15x
🌐 MongoDBNext.js (Turbopack)12.197s (~)13.019s (~)0.822s51.15x
🐘 PostgresExpress16.081s (-21.2% 🟢)16.837s (-20.0% 🟢)0.756s51.52x
🐘 PostgresNitro18.950s (-6.9% 🟢)19.634s (-5.8% 🟢)0.683s51.79x
🐘 PostgresNext.js (Turbopack)20.287s (+33.3% 🔺)21.046s (+31.2% 🔺)0.760s51.92x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro21.714s (+0.7%)22.834s (+1.8%)1.120s51.00x
▲ VercelExpress21.984s (+1.6%)23.013s (+3.2%)1.029s51.01x
▲ VercelNext.js (Turbopack)22.185s (+1.8%)23.116s (+2.5%)0.932s51.02x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Starter🥇 Next.js (Turbopack)1.334s (~)2.007s (~)0.673s151.00x
🌐 RedisNext.js (Turbopack)1.365s (+0.6%)2.011s (~)0.646s151.02x
💻 LocalNext.js (Turbopack)1.373s (-1.6%)2.010s (~)0.636s151.03x
💻 LocalNitro1.408s (~)2.005s (~)0.597s151.06x
💻 LocalExpress1.408s (~)2.006s (~)0.598s151.06x
🐘 PostgresNext.js (Turbopack)1.901s (+3.1%)2.164s (+4.7%)0.263s141.43x
🐘 PostgresExpress1.939s (-18.7% 🟢)2.225s (-26.1% 🟢)0.287s141.45x
🌐 MongoDBNext.js (Turbopack)2.130s (~)3.011s (~)0.881s101.60x
🌐 TursoNext.js (Turbopack)2.198s (~)3.014s (~)0.815s101.65x
🐘 PostgresNitro2.351s (~)2.921s (-3.0%)0.569s111.76x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.844s (+4.8%)3.777s (-0.9%)0.933s81.00x
▲ VercelNext.js (Turbopack)2.994s (-1.9%)3.767s (-5.3% 🟢)0.774s81.05x
▲ VercelExpress3.634s (+32.8% 🔺)4.564s (+29.8% 🔺)0.930s71.28x

🔍 Observability: Nitro | Next.js (Turbopack) | Express

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)2.081s (-3.8%)2.911s (-6.6% 🟢)0.830s111.00x
💻 LocalExpress2.206s (~)3.160s (~)0.954s101.06x
💻 LocalNitro2.224s (~)3.169s (~)0.945s101.07x
🌐 StarterNext.js (Turbopack)2.448s (~)3.009s (~)0.560s101.18x
🌐 RedisNext.js (Turbopack)2.506s (+0.5%)3.012s (~)0.507s101.20x
🐘 PostgresExpress2.704s (-7.4% 🟢)3.016s (-6.8% 🟢)0.312s101.30x
🐘 PostgresNitro2.713s (+2.2%)3.012s (-0.5%)0.298s101.30x
🐘 PostgresNext.js (Turbopack)2.920s (+10.9% 🔺)3.411s (+12.5% 🔺)0.491s91.40x
🌐 MongoDBNext.js (Turbopack)4.685s (-1.8%)5.180s (~)0.495s62.25x
🌐 TursoNext.js (Turbopack)4.704s (+0.7%)5.178s (~)0.474s62.26x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.558s (+16.9% 🔺)4.236s (+15.0% 🔺)0.678s81.00x
▲ VercelNext.js (Turbopack)3.664s (+15.3% 🔺)4.434s (+16.2% 🔺)0.770s71.03x
▲ VercelNitro3.779s (+21.7% 🔺)4.309s (+12.4% 🔺)0.530s91.06x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Starter🥇 Next.js (Turbopack)1.350s (-1.6%)2.007s (~)0.656s151.00x
🌐 RedisNext.js (Turbopack)1.371s (-1.1%)2.009s (~)0.638s151.02x
💻 LocalNext.js (Turbopack)1.388s (-0.7%)2.013s (~)0.625s151.03x
💻 LocalExpress1.408s (~)2.006s (~)0.598s151.04x
💻 LocalNitro1.431s (~)2.005s (~)0.574s151.06x
🐘 PostgresExpress1.706s (-3.4%)2.009s (~)0.303s151.26x
🐘 PostgresNitro1.775s (-5.3% 🟢)2.011s (~)0.236s151.31x
🐘 PostgresNext.js (Turbopack)2.026s (+24.5% 🔺)2.231s (+10.8% 🔺)0.206s141.50x
🌐 MongoDBNext.js (Turbopack)2.176s (+2.7%)3.011s (~)0.835s101.61x
🌐 TursoNext.js (Turbopack)2.233s (+0.7%)3.015s (~)0.782s101.65x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.872s (+7.4% 🔺)3.757s (~)0.885s81.00x
▲ VercelExpress2.879s (+6.2% 🔺)4.136s (+17.9% 🔺)1.257s81.00x
▲ VercelNext.js (Turbopack)2.974s (+11.8% 🔺)4.393s (+20.0% 🔺)1.419s71.04x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)2.056s (-10.4% 🟢)2.916s (-10.1% 🟢)0.860s111.00x
💻 LocalExpress2.221s (-1.0%)3.152s (-1.1%)0.931s101.08x
💻 LocalNitro2.249s (-5.9% 🟢)3.178s (-4.6%)0.929s101.09x
🌐 StarterNext.js (Turbopack)2.458s (~)3.010s (~)0.552s101.20x
🐘 PostgresExpress2.502s (-8.1% 🟢)3.017s (~)0.515s101.22x
🌐 RedisNext.js (Turbopack)2.508s (+0.8%)3.012s (~)0.504s101.22x
🐘 PostgresNext.js (Turbopack)2.734s (+3.0%)3.028s (~)0.294s101.33x
🐘 PostgresNitro2.791s (+19.2% 🔺)3.038s (+0.9%)0.246s101.36x
🌐 TursoNext.js (Turbopack)4.693s (-1.6%)5.181s (~)0.488s62.28x
🌐 MongoDBNext.js (Turbopack)4.748s (+0.9%)5.179s (~)0.431s62.31x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.181s (+7.2% 🔺)3.741s (+1.6%)0.559s91.00x
▲ VercelExpress3.352s (+4.0%)3.953s (+3.9%)0.601s81.05x
▲ VercelNext.js (Turbopack)3.540s (+10.7% 🔺)4.341s (+11.9% 🔺)0.800s71.11x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🌐 Starter🥇 Next.js (Turbopack)0.125s (-3.2%)1.005s (~)0.000s (+Infinity% 🔺)1.010s (~)0.885s101.00x
💻 LocalNext.js (Turbopack)0.135s (-8.2% 🟢)1.003s (~)0.019s (+14.1% 🔺)1.029s (~)0.895s101.08x
🌐 RedisNext.js (Turbopack)0.141s (-1.1%)1.005s (~)0.000s (+Infinity% 🔺)1.013s (~)0.872s101.13x
💻 LocalNitro0.176s (~)0.992s (~)0.015s (-6.9% 🟢)1.021s (~)0.845s101.41x
💻 LocalExpress0.176s (~)0.992s (~)0.016s (~)1.022s (~)0.846s101.41x
🌐 TursoNext.js (Turbopack)0.461s (-5.9% 🟢)0.985s (+2.5%)0.000s (~)1.012s (~)0.551s103.69x
🌐 MongoDBNext.js (Turbopack)0.523s (+1.9%)0.925s (-1.1%)0.000s (+Infinity% 🔺)1.013s (~)0.490s104.18x
🐘 PostgresExpress1.353s (-41.6% 🟢)1.687s (-38.1% 🟢)0.000s (+200.0% 🔺)2.012s (-33.3% 🟢)0.659s1010.81x
🐘 PostgresNitro1.370s (-39.7% 🟢)1.670s (-39.7% 🟢)0.000s (-100.0% 🟢)2.012s (-33.2% 🟢)0.642s1010.95x
🐘 PostgresNext.js (Turbopack)1.608s (+35.8% 🔺)2.296s (+34.4% 🔺)0.000s (+Infinity% 🔺)2.418s (+32.9% 🔺)0.811s1012.85x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.631s (-11.4% 🟢)3.110s (-3.9%)0.657s (-8.1% 🟢)4.223s (-6.0% 🟢)1.592s101.00x
▲ VercelNitro2.770s (+4.7%)3.322s (+3.1%)0.567s (-15.4% 🟢)4.412s (+2.5%)1.643s101.05x
▲ VercelNext.js (Turbopack)2.788s (+6.0% 🔺)3.253s (+1.8%)0.832s (+45.0% 🔺)4.510s (+7.5% 🔺)1.722s101.06x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNext.js (Turbopack)8/8
🐘 PostgresExpress5/8
▲ VercelNitro4/8
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express💻 Local8/8
Next.js (Turbopack)🌐 Starter6/8
Nitro💻 Local8/8
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Starter: Community world (local development)
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)

📋 View full workflow run

@github-actions

github-actionsBot commented Dec 20, 2025

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production286011297
✅ 💻 Local Development26208270
✅ 📦 Local Production26208270
✅ 🐘 Local Postgres26208270
✅ 🪟 Windows270027
❌ 🌍 Community Worlds109110120
Total120811351254

❌ Failed Tests

🌍 Community Worlds (11 failed)

mongodb (1 failed):

  • webhookWorkflow

redis (1 failed):

  • webhookWorkflow

starter (8 failed):

  • addTenWorkflow
  • addTenWorkflow
  • retryAttemptCounterWorkflow
  • crossFileErrorWorkflow - stack traces work across imported modules
  • hookCleanupTestWorkflow - hook token reuse after workflow completion
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step

turso (1 failed):

  • webhookWorkflow

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro2601
✅ example2601
✅ express2601
✅ fastify2601
✅ hono2601
✅ nextjs-turbopack2601
✅ nextjs-webpack2601
✅ nitro2601
✅ nuxt2601
✅ sveltekit2601
✅ vite2601
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable2601
✅ express-stable2601
✅ fastify-stable2601
✅ hono-stable2601
✅ nextjs-turbopack-stable2700
✅ nextjs-webpack-stable2700
✅ nitro-stable2601
✅ nuxt-stable2601
✅ sveltekit-stable2601
✅ vite-stable2601
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable2601
✅ express-stable2601
✅ fastify-stable2601
✅ hono-stable2601
✅ nextjs-turbopack-stable2700
✅ nextjs-webpack-stable2700
✅ nitro-stable2601
✅ nuxt-stable2601
✅ sveltekit-stable2601
✅ vite-stable2601
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable2601
✅ express-stable2601
✅ fastify-stable2601
✅ hono-stable2601
✅ nextjs-turbopack-stable2700
✅ nextjs-webpack-stable2700
✅ nitro-stable2601
✅ nuxt-stable2601
✅ sveltekit-stable2601
✅ vite-stable2601
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack2700
❌ 🌍 Community Worlds
AppPassedFailedSkipped
✅ mongodb-dev300
❌ mongodb2610
✅ redis-dev300
❌ redis2610
✅ starter-dev300
❌ starter1980
✅ turso-dev300
❌ turso2610

📋 View full workflow run

@vercel

vercelBot commented Dec 20, 2025

Copy link
Copy Markdown
Contributor

@TooTallNateGraphite App

TooTallNate commented Dec 20, 2025

Copy link
Copy Markdown
MemberAuthor

Comment on lines +356 to +362
} catch (err) {
state.streamEnded = true;
if (!state.doneResolved) {
state.doneResolved = true;
state.reject(err);
}
throw err;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stream locks are not released when an error occurs in the pipe. The error is thrown without releasing the locks acquired at the start of the function.

View Details
📝 Patch Details
diff --git a/packages/core/src/serialization.ts b/packages/core/src/serialization.ts
index c8ebc73..23e2ae6 100644
--- a/packages/core/src/serialization.ts+++ b/packages/core/src/serialization.ts@@ -359,6 +359,8 @@ async function flushablePipe(
state.doneResolved = true;
state.reject(err);
}
+ reader.releaseLock();+ writer.releaseLock();
throw err;
}
}

Analysis

Stream locks not released when error occurs in flushablePipe

What fails: The flushablePipe() function in packages/core/src/serialization.ts does not release the reader and writer locks when an error is thrown in the catch block (lines 356-362), leaving the streams in a permanently locked state.

How to reproduce: Create a test scenario where an error occurs during pipe operation:

// Create a readable streamconstsource=newReadableStream({start(controller){controller.enqueue('chunk1');controller.enqueue('chunk2');controller.close();}});// Create a writable stream that errors on second writeletwriteCount=0;constsink=newWritableStream({asyncwrite(chunk){writeCount++;if(writeCount===2){thrownewError('Write failed!');}}});// Call flushablePipe with error stateconststate={pendingOps: 0,doneResolved: false,streamEnded: false,resolve: ()=>{},reject: ()=>{}};try{awaitflushablePipe(source,sink,state);}catch(err){// Error caught}// Attempt to acquire new reader/writersource.getReader();// Throws: "InvalidStateError: ReadableStream is locked"sink.getWriter();// Throws: "InvalidStateError: WritableStream is locked"

Result: Streams remain locked (source.locked === true, sink.locked === true), preventing new readers/writers from being obtained. Cannot acquire new reader: "InvalidStateError: ReadableStream is locked". Cannot acquire new writer: "InvalidStateError: WritableStream is locked".

Expected behavior: The locks should be released before throwing the error, as they are in normal code paths (lines 326, 339, 346). Per WHATWG Streams API specification, a lock must be explicitly released, and a locked stream prevents other readers/writers from being obtained. Leaving locks unreleased creates a resource leak that prevents stream reuse.

Root cause: The catch block at lines 356-362 sets error state and throws without calling reader.releaseLock() and writer.releaseLock(), unlike all other exit paths from the function which properly release locks.

Fix applied: Added reader.releaseLock() and writer.releaseLock() before the throw err statement in the catch block to match the cleanup pattern used in normal code paths.

Comment on lines +329 to +338
if (readResult.done) {
// Source stream completed - close sink and resolve
state.streamEnded = true;
await writer.close();
// Resolve done promise if not already resolved
if (!state.doneResolved) {
state.doneResolved = true;
state.resolve();
}
return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stream locks are not released when the source stream ends. The flushablePipe function acquires locks on the reader and writer but doesn't release them before returning when the readable stream completes.

View Details
📝 Patch Details
diff --git a/packages/core/src/serialization.ts b/packages/core/src/serialization.ts
index c8ebc73..57e4750 100644
--- a/packages/core/src/serialization.ts+++ b/packages/core/src/serialization.ts@@ -335,6 +335,8 @@ async function flushablePipe(
state.doneResolved = true;
state.resolve();
}
+ reader.releaseLock();+ writer.releaseLock();
return;
}
@@ -359,6 +361,8 @@ async function flushablePipe(
state.doneResolved = true;
state.reject(err);
}
+ reader.releaseLock();+ writer.releaseLock();
throw err;
}
}

Analysis

Stream locks not released in flushablePipe() when source completes or errors

What fails: The flushablePipe() function in packages/core/src/serialization.ts (lines 333-341 and 365-371) acquires locks on ReadableStream and WritableStream but fails to release them before returning when the stream completes or when an error occurs.

How to reproduce:

// When readResult.done is true (source stream completed):constreadResult=awaitreader.read();if(readResult.done){state.streamEnded=true;awaitwriter.close();// ... resolve promise ...return;// ❌ Returns without releasing locks}// Or when error occurs:}catch(err){state.streamEnded=true;// ... reject promise ...throwerr;// ❌ Throws without releasing locks}

Result: Unreleased locks prevent any other code from acquiring new readers or writers on these streams. Subsequent attempts to call getReader() or getWriter() will throw TypeError: ReadableStreamDefaultReader constructor can only accept readable streams that are not yet locked to a reader. The stream becomes permanently inaccessible.

Expected behavior: According to MDN Web JavaScript resource management, unreleased locks cause the stream to remain locked indefinitely and "does not permit another reader to consume it." The locks must be explicitly released via reader.releaseLock() and writer.releaseLock() before the function exits.

Fix: Added reader.releaseLock() and writer.releaseLock() calls before all return/throw statements in the flushablePipe function:

  • Before returning when readResult.done (normal stream completion)
  • Before throwing in the catch block (error case)

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements a polling mechanism to detect when stream locks are released, allowing workflow steps to complete without waiting for streams to fully close. This fixes an issue where Vercel functions would hang when users write to streams and release locks without explicitly closing them.

Key Changes:

  • Added flushablePipe function that resolves when either the stream completes or when the user releases their lock with all pending writes flushed
  • Implemented polling functions (pollWritableLock and pollReadableLock) that check stream lock state every 100ms
  • Created state tracking system (FlushableStreamState) to monitor pending operations and resolution status

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 15 comments.

FileDescription
packages/core/src/serialization.tsImplements the core flushable pipe mechanism with polling-based lock detection and state tracking for both readable and writable streams
packages/core/src/serialization.test.tsAdds tests verifying the new behavior for both lock release and natural stream closure scenarios
.changeset/stream-lock-polling.mdDocuments the patch-level change for the @workflow/core package

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +1174 to +1176
flushablePipe(readable, transform.writable, state).catch(() => {
// Errors are handled via state.reject
});

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The flushablePipe function throws errors after calling state.reject() (line 405), but all call sites catch and ignore these thrown errors with empty catch blocks (e.g., line 976, 992, 1016). This pattern is confusing because the error is both rejected through the state AND thrown. Since the error is already propagated via state.reject() which rejects the 'done' promise, the re-throw serves no purpose and the empty catch blocks make this unclear. Consider either removing the throw from flushablePipe or documenting why the error needs to be both rejected and thrown.

Copilot uses AI. Check for mistakes.
Comment on lines +399 to +406
} catch (err) {
state.streamEnded = true;
if (!state.doneResolved) {
state.doneResolved = true;
state.reject(err);
}
throw err;
}

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error handling in the flushablePipe function could potentially leak resources. When an exception occurs in the catch block at line 399, the reader and writer locks are not released before the error is re-thrown. This could leave the streams in a locked state. Consider releasing the locks in the catch block before re-throwing.

Copilot uses AI. Check for mistakes.
Comment on lines +229 to +261
/**
* Checks if a WritableStream is unlocked (user released lock) vs closed.
* When a stream is closed, .locked is false but getWriter() throws.
* We only want to resolve via polling when the stream is unlocked, not closed.
* If closed, the pump will handle resolution via the stream ending naturally.
*/
function isWritableUnlockedNotClosed(writable: WritableStream): boolean {
if (writable.locked) return false;

try {
// Try to acquire writer - if successful, stream is unlocked (not closed)
const writer = writable.getWriter();
writer.releaseLock();
return true;
} catch {
// getWriter() throws if stream is closed/errored - let pump handle it
return false;
}
}

/**
* Checks if a ReadableStream is unlocked (user released lock) vs closed.
*/
function isReadableUnlockedNotClosed(readable: ReadableStream): boolean {
if (readable.locked) return false;

try {
// Try to acquire reader - if successful, stream is unlocked (not closed)
const reader = readable.getReader();
reader.releaseLock();
return true;
} catch {
// getReader() throws if stream is closed/errored - let pump handle it

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The polling functions acquire and release stream locks every 100ms to check if a stream is unlocked vs closed. This repeated lock acquisition could have performance implications and might interfere with stream operations. Consider using a different approach to detect stream closure, such as checking for specific error messages or using a flag that's set when the stream closes, to avoid repeatedly acquiring locks during polling.

Suggested change
/**
*ChecksifaWritableStreamisunlocked(userreleasedlock)vsclosed.
*Whenastreamisclosed,.lockedisfalsebutgetWriter()throws.
*Weonlywanttoresolveviapollingwhenthestreamisunlocked,notclosed.
*Ifclosed,thepumpwillhandleresolutionviathestreamendingnaturally.
*/
functionisWritableUnlockedNotClosed(writable: WritableStream): boolean{
if(writable.locked)returnfalse;
try{
// Try to acquire writer - if successful, stream is unlocked (not closed)
constwriter=writable.getWriter();
writer.releaseLock();
returntrue;
}catch{
// getWriter() throws if stream is closed/errored - let pump handle it
returnfalse;
}
}
/**
*ChecksifaReadableStreamisunlocked(userreleasedlock)vsclosed.
*/
functionisReadableUnlockedNotClosed(readable: ReadableStream): boolean{
if(readable.locked)returnfalse;
try{
// Try to acquire reader - if successful, stream is unlocked (not closed)
constreader=readable.getReader();
reader.releaseLock();
returntrue;
}catch{
// getReader() throws if stream is closed/errored - let pump handle it
/**
*Cacheofper-stream"unlocked but not closed"statusforWritableStreams.
*Thisavoidsrepeatedlyacquiringandreleasinglocksduringpolling;we
*probethestreamatmostonceperinstancewhenittransitionstounlocked.
*/
constwritableUnlockedNotClosedCache=newWeakMap<WritableStream,boolean>();
/**
*ChecksifaWritableStreamisunlocked(userreleasedlock)vsclosed.
*Whenastreamisclosed,.lockedisfalsebutgetWriter()throws.
*Weonlywanttoresolveviapollingwhenthestreamisunlocked,notclosed.
*Ifclosed,thepumpwillhandleresolutionviathestreamendingnaturally.
*
*Toavoidrepeatedlockacquisitionduringpolling,wecachetheresultof
*theunlocked-vs-closedprobeperstream.Oncedetermined,thestatus is
*reusedonsubsequentcallsforthesamestream.
*/
functionisWritableUnlockedNotClosed(writable: WritableStream): boolean{
if(writable.locked)returnfalse;
constcached=writableUnlockedNotClosedCache.get(writable);
if(cached!==undefined){
returncached;
}
try{
// Try to acquire writer - if successful, stream is unlocked (not closed)
constwriter=writable.getWriter();
writer.releaseLock();
writableUnlockedNotClosedCache.set(writable,true);
returntrue;
}catch{
// getWriter() throws if stream is closed/errored - let pump handle it
writableUnlockedNotClosedCache.set(writable,false);
returnfalse;
}
}
/**
*Cacheofper-stream"unlocked but not closed"statusforReadableStreams.
*Likethewritablecache,thispreventsrepeatedreaderacquisitionswhile
*pollingforlockrelease.
*/
constreadableUnlockedNotClosedCache=newWeakMap<ReadableStream,boolean>();
/**
*ChecksifaReadableStreamisunlocked(userreleasedlock)vsclosed.
*Usesaper-streamcachetoavoidrepeatedlyacquiringandreleasinglocks
*duringpolling.
*/
functionisReadableUnlockedNotClosed(readable: ReadableStream): boolean{
if(readable.locked)returnfalse;
constcached=readableUnlockedNotClosedCache.get(readable);
if(cached!==undefined){
returncached;
}
try{
// Try to acquire reader - if successful, stream is unlocked (not closed)
constreader=readable.getReader();
reader.releaseLock();
readableUnlockedNotClosedCache.set(readable,true);
returntrue;
}catch{
// getReader() throws if stream is closed/errored - let pump handle it
readableUnlockedNotClosedCache.set(readable,false);

Copilot uses AI. Check for mistakes.
Comment on lines +1179 to +1186
} catch (err) {
state.streamEnded = true;
if (!state.doneResolved) {
state.doneResolved = true;
state.reject(err as Error);
}
}
})();

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error handling in the test's pipe implementation (line 1179-1185) doesn't release the reader and writer locks before exiting, similar to the issue in the actual flushablePipe implementation. While this mirrors the production code, it would be better to release the locks in the catch block to properly clean up resources. Consider updating the test to match the corrected implementation once the production code is fixed.

Copilot uses AI. Check for mistakes.
Comment on lines +1016 to +1018
flushablePipe(serialize.readable, serverWritable, state).catch(() => {
// Errors are handled via state.reject
});

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The flushablePipe function throws errors after calling state.reject() (line 405), but all call sites catch and ignore these thrown errors with empty catch blocks (e.g., line 976, 992, 1016). This pattern is confusing because the error is both rejected through the state AND thrown. Since the error is already propagated via state.reject() which rejects the 'done' promise, the re-throw serves no purpose and the empty catch blocks make this unclear. Consider either removing the throw from flushablePipe or documenting why the error needs to be both rejected and thrown.

Suggested change
flushablePipe(serialize.readable,serverWritable,state).catch(()=>{
// Errors are handled via state.reject
});
flushablePipe(serialize.readable,serverWritable,state).catch(
(error)=>{
// flushablePipe already calls state.reject(error), which rejects the
// `done` promise added to `ops`. We catch and intentionally ignore
// this rejection here only to avoid an unhandled promise rejection;
// all real error handling flows through state.done.
}
);

Copilot uses AI. Check for mistakes.
Comment on lines +1001 to +1229
describe('flushable stream behavior', () => {
const POLL_INTERVAL = 100; // Match the actual implementation
const STABLE_POLL_COUNT = 2; // Match the actual implementation

it('done promise should resolve when writable stream lock is released (polling)', async () => {
// Test the pattern: user writes, releases lock, polling detects it, done resolves
const chunks: string[] = [];
let streamClosed = false;

// Create a simple mock for the sink
const mockSink = new WritableStream<string>({
write(chunk) {
chunks.push(chunk);
},
close() {
streamClosed = true;
},
});

// Create a TransformStream like we do in getStepRevivers
const { readable, writable } = new TransformStream<string, string>();

// Track flushable state - this mirrors the actual implementation
const state = {
pendingOps: 0, // Only counts writes to server
doneResolved: false,
streamEnded: false,
resolve: () => {},
reject: (_err: Error) => {},
};

const done = new Promise<void>((res, rej) => {
state.resolve = res;
state.reject = rej;
});

// Start piping in background (mirrors flushablePipe implementation)
(async () => {
const reader = readable.getReader();
const writer = mockSink.getWriter();
try {
while (!state.streamEnded) {
const result = await reader.read();

if (result.done) {
state.streamEnded = true;
await writer.close();
if (!state.doneResolved) {
state.doneResolved = true;
state.resolve();
}
return;
}

// Only writes count as pending ops
state.pendingOps++;
await writer.write(result.value);
state.pendingOps--;

if (state.streamEnded) {
reader.releaseLock();
writer.releaseLock();
return;
}
}
} catch (err) {
state.streamEnded = true;
if (!state.doneResolved) {
state.doneResolved = true;
state.reject(err as Error);
}
}
})();

// Start polling (mirrors pollWritableLock implementation)
let stableCount = 0;
const intervalId = setInterval(() => {
if (state.doneResolved || state.streamEnded) {
clearInterval(intervalId);
return;
}

// Check if lock is released by checking .locked property
if (!writable.locked && state.pendingOps === 0) {
stableCount++;
if (stableCount >= STABLE_POLL_COUNT) {
state.doneResolved = true;
state.resolve();
clearInterval(intervalId);
}
} else {
stableCount = 0;
}
}, POLL_INTERVAL);

// Simulate user interaction - write and release lock
const userWriter = writable.getWriter();
await userWriter.write('chunk1');
await userWriter.write('chunk2');

// Release lock without closing stream
userWriter.releaseLock();

// Wait for pipe to process + polling intervals (need STABLE_POLL_COUNT consecutive polls)
await new Promise((r) => setTimeout(r, 250));

// The done promise should resolve
await expect(
Promise.race([
done,
new Promise((_, r) => setTimeout(() => r(new Error('timeout')), 400)),
])
).resolves.toBeUndefined();

// Chunks should have been written
expect(chunks).toContain('chunk1');
expect(chunks).toContain('chunk2');

// Stream should NOT be closed (user only released lock)
expect(streamClosed).toBe(false);
});

it('done promise should resolve when writable stream closes naturally', async () => {
const chunks: string[] = [];
let streamClosed = false;

const mockSink = new WritableStream<string>({
write(chunk) {
chunks.push(chunk);
},
close() {
streamClosed = true;
},
});

const { readable, writable } = new TransformStream<string, string>();

const state = {
pendingOps: 0,
doneResolved: false,
streamEnded: false,
resolve: () => {},
reject: (_err: Error) => {},
};

const done = new Promise<void>((res, rej) => {
state.resolve = res;
state.reject = rej;
});

// Start piping in background
(async () => {
const reader = readable.getReader();
const writer = mockSink.getWriter();
try {
while (!state.streamEnded) {
const result = await reader.read();

if (result.done) {
state.streamEnded = true;
await writer.close();
if (!state.doneResolved) {
state.doneResolved = true;
state.resolve();
}
return;
}

state.pendingOps++;
await writer.write(result.value);
state.pendingOps--;

if (state.streamEnded) {
reader.releaseLock();
writer.releaseLock();
return;
}
}
} catch (err) {
state.streamEnded = true;
if (!state.doneResolved) {
state.doneResolved = true;
state.reject(err as Error);
}
}
})();

// Start polling (won't trigger since stream will close first)
let stableCount2 = 0;
const intervalId = setInterval(() => {
if (state.doneResolved || state.streamEnded) {
clearInterval(intervalId);
return;
}
if (!writable.locked && state.pendingOps === 0) {
stableCount2++;
if (stableCount2 >= STABLE_POLL_COUNT) {
state.doneResolved = true;
state.resolve();
clearInterval(intervalId);
}
} else {
stableCount2 = 0;
}
}, POLL_INTERVAL);

// User writes and then closes the stream
const userWriter = writable.getWriter();
await userWriter.write('data');
await userWriter.close();

// Wait a tick for the pipe to process
await new Promise((r) => setTimeout(r, 50));

// The done promise should resolve
await expect(
Promise.race([
done,
new Promise((_, r) => setTimeout(() => r(new Error('timeout')), 200)),
])
).resolves.toBeUndefined();

// Chunks should have been written
expect(chunks).toContain('data');

// Stream should be closed (user closed it)
expect(streamClosed).toBe(true);
});
});

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new flushable stream behavior tests only cover the happy paths (lock release and normal closure). Consider adding test cases for error scenarios such as: stream errors during write operations, reader errors, and what happens when the stream is aborted. This would help ensure the error handling in flushablePipe and the polling mechanisms work correctly under failure conditions.

Copilot uses AI. Check for mistakes.
Comment on lines +1158 to +1160
flushablePipe(readable, writable, state).catch(() => {
// Errors are handled via state.reject
});

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The flushablePipe function throws errors after calling state.reject() (line 405), but all call sites catch and ignore these thrown errors with empty catch blocks (e.g., line 976, 992, 1016). This pattern is confusing because the error is both rejected through the state AND thrown. Since the error is already propagated via state.reject() which rejects the 'done' promise, the re-throw serves no purpose and the empty catch blocks make this unclear. Consider either removing the throw from flushablePipe or documenting why the error needs to be both rejected and thrown.

Copilot uses AI. Check for mistakes.
Comment on lines +192 to +196
* - `doneResolved`: The `done` promise has been resolved (step can complete)
* - `streamEnded`: The underlying stream has actually closed/errored
*
* The pump continues running even after `doneResolved=true` to handle
* any future writes if the user acquires a new lock.

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment states that "The pump continues running even after doneResolved=true to handle any future writes if the user acquires a new lock." However, this design could be problematic. Once the done promise resolves (pushed to ops), the workflow step will complete. If the user then acquires a new lock and writes more data after the step has completed, it could lead to unexpected behavior where data is being written to a stream associated with an already-completed step. Consider whether this is the intended behavior or if the stream should be closed/locked after the done promise resolves.

Suggested change
*-`doneResolved`: The`done`promisehasbeenresolved(stepcancomplete)
*-`streamEnded`: Theunderlyingstreamhasactuallyclosed/errored
*
*Thepumpcontinuesrunningevenafter`doneResolved=true`tohandle
* any futurewritesiftheuseracquiresanewlock.
*-`doneResolved`: The`done`promisehasbeenresolved(stepcancomplete),
*andnofurtherwritesshouldbeacceptedforthisstream.
*-`streamEnded`: Theunderlyingstreamhasactuallyclosed/errored.
*
*Once`doneResolved`becomes`true`,thepumpshouldonlydrainany
*already-pendingoperationsandthenstop;itmustnothandlenewwrites
*fromsubsequentlyacquiredlocks.

Copilot uses AI. Check for mistakes.
Comment on lines +1066 to +1073
} catch (err) {
state.streamEnded = true;
if (!state.doneResolved) {
state.doneResolved = true;
state.reject(err as Error);
}
}
})();

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error handling in the test's pipe implementation (line 1066-1072) doesn't release the reader and writer locks before exiting, similar to the issue in the actual flushablePipe implementation. While this mirrors the production code, it would be better to release the locks in the catch block to properly clean up resources. Consider updating the test to match the corrected implementation once the production code is fixed.

Copilot uses AI. Check for mistakes.
Comment on lines +1189 to +1203
let stableCount2 = 0;
const intervalId = setInterval(() => {
if (state.doneResolved || state.streamEnded) {
clearInterval(intervalId);
return;
}
if (!writable.locked && state.pendingOps === 0) {
stableCount2++;
if (stableCount2 >= STABLE_POLL_COUNT) {
state.doneResolved = true;
state.resolve();
clearInterval(intervalId);
}
} else {
stableCount2 = 0;

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The variable name stableCount2 is unclear and suggests copy-paste from the first test. Consider renaming to stableCount for consistency, as the numbering doesn't add clarity and makes the code harder to read.

Suggested change
letstableCount2=0;
constintervalId=setInterval(()=>{
if(state.doneResolved||state.streamEnded){
clearInterval(intervalId);
return;
}
if(!writable.locked&&state.pendingOps===0){
stableCount2++;
if(stableCount2>=STABLE_POLL_COUNT){
state.doneResolved=true;
state.resolve();
clearInterval(intervalId);
}
}else{
stableCount2=0;
letstableCount=0;
constintervalId=setInterval(()=>{
if(state.doneResolved||state.streamEnded){
clearInterval(intervalId);
return;
}
if(!writable.locked&&state.pendingOps===0){
stableCount++;
if(stableCount>=STABLE_POLL_COUNT){
state.doneResolved=true;
state.resolve();
clearInterval(intervalId);
}
}else{
stableCount=0;

Copilot uses AI. Check for mistakes.

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before I review - there's a ton of AI comments on this PR and I want to give you a chance to resolve those, so I'm subscribing and re-reviewing after the next commit/message

Base automatically changed from 12-22-fix_race_conditions_in_webhook_response_handling to mainDecember 22, 2025 18:41
@TooTallNate

Copy link
Copy Markdown
MemberAuthor

@copilot Please evaluate the code review suggestions in this PR and apply them if you think they are relevent.

CopilotAI commented Dec 22, 2025

Copy link
Copy Markdown
Contributor

@TooTallNate I've opened a new pull request, #677, to work on those changes. Once the pull request is ready, I'll request review from you.

…f waiting for stream to close
This prevents Vercel functions from hanging when users incrementally write to streams within steps (e.g., `await writer.write(data); writer.releaseLock()`). Uses a polling approach to detect when the stream lock is released and all pending writes are flushed.
Comment on lines +271 to +316
function pollWritableLock(
writable: WritableStream,
state: FlushableStreamState
): void {
const intervalId = setInterval(() => {
// Stop polling if already resolved or stream ended
if (state.doneResolved || state.streamEnded) {
clearInterval(intervalId);
return;
}

// Check if lock is released (not closed) and no pending ops
if (isWritableUnlockedNotClosed(writable) && state.pendingOps === 0) {
state.doneResolved = true;
state.resolve();
clearInterval(intervalId);
}
}, LOCK_POLL_INTERVAL_MS);
}

/**
* Polls a ReadableStream to check if the user has released their lock.
* Resolves the done promise when lock is released and no pending ops remain.
*
* Note: Only resolves if stream is unlocked but NOT closed. If the user closes
* the stream, the pump will handle resolution via the stream ending naturally.
*/
function pollReadableLock(
readable: ReadableStream,
state: FlushableStreamState
): void {
const intervalId = setInterval(() => {
// Stop polling if already resolved or stream ended
if (state.doneResolved || state.streamEnded) {
clearInterval(intervalId);
return;
}

// Check if lock is released (not closed) and no pending ops
if (isReadableUnlockedNotClosed(readable) && state.pendingOps === 0) {
state.doneResolved = true;
state.resolve();
clearInterval(intervalId);
}
}, LOCK_POLL_INTERVAL_MS);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The polling functions don't implement the "multiple consecutive polls" behavior mentioned in the docstring, creating a potential race condition where the done promise resolves before the pump finishes writing queued data.

View Details
📝 Patch Details
diff --git a/packages/core/src/serialization.ts b/packages/core/src/serialization.ts
index 69c391b..376afe4 100644
--- a/packages/core/src/serialization.ts+++ b/packages/core/src/serialization.ts@@ -182,6 +182,9 @@ export class WorkflowServerWritableStream extends WritableStream<Uint8Array> {
/** Polling interval for checking stream lock state (in milliseconds) */
const LOCK_POLL_INTERVAL_MS = 100;
+/** Number of consecutive stable polls before resolving done promise */+const STABLE_POLL_COUNT = 2;+
/**
* State tracker for flushable stream operations.
* Resolves when either:
@@ -272,6 +275,7 @@ function pollWritableLock(
writable: WritableStream,
state: FlushableStreamState
): void {
+ let stableCount = 0;
const intervalId = setInterval(() => {
// Stop polling if already resolved or stream ended
if (state.doneResolved || state.streamEnded) {
@@ -281,16 +285,22 @@ function pollWritableLock(
// Check if lock is released (not closed) and no pending ops
if (isWritableUnlockedNotClosed(writable) && state.pendingOps === 0) {
- state.doneResolved = true;- state.resolve();- clearInterval(intervalId);+ stableCount++;+ if (stableCount >= STABLE_POLL_COUNT) {+ state.doneResolved = true;+ state.resolve();+ clearInterval(intervalId);+ }+ } else {+ stableCount = 0;
}
}, LOCK_POLL_INTERVAL_MS);
}
/**
* Polls a ReadableStream to check if the user has released their lock.
- * Resolves the done promise when lock is released and no pending ops remain.+ * Resolves the done promise when lock is released and no pending ops remain+ * for multiple consecutive polls (to avoid race conditions with the pump).
*
* Note: Only resolves if stream is unlocked but NOT closed. If the user closes
* the stream, the pump will handle resolution via the stream ending naturally.
@@ -299,6 +309,7 @@ function pollReadableLock(
readable: ReadableStream,
state: FlushableStreamState
): void {
+ let stableCount = 0;
const intervalId = setInterval(() => {
// Stop polling if already resolved or stream ended
if (state.doneResolved || state.streamEnded) {
@@ -308,9 +319,14 @@ function pollReadableLock(
// Check if lock is released (not closed) and no pending ops
if (isReadableUnlockedNotClosed(readable) && state.pendingOps === 0) {
- state.doneResolved = true;- state.resolve();- clearInterval(intervalId);+ stableCount++;+ if (stableCount >= STABLE_POLL_COUNT) {+ state.doneResolved = true;+ state.resolve();+ clearInterval(intervalId);+ }+ } else {+ stableCount = 0;
}
}, LOCK_POLL_INTERVAL_MS);
}

Analysis

Race condition in polling functions: done promise resolves before pump finishes flushing

What fails: The pollWritableLock() and pollReadableLock() functions in packages/core/src/serialization.ts resolve the done promise immediately when the stream lock is released and pendingOps === 0, without waiting for multiple consecutive stable polls. This creates a race condition where the done promise can resolve while the pump is still reading/writing data.

How to reproduce:

  1. A stream is being piped asynchronously via flushablePipe()
  2. User releases the lock on the stream (but doesn't close it)
  3. Pump is momentarily between reads (at await reader.read()) with pendingOps === 0
  4. Polling fires and sees: lock released + pendingOps === 0 → resolves done promise immediately
  5. Runtime awaits Promise.all(ops) which includes the done promise and proceeds
  6. If pump reads more data and tries to write, the step is already marked complete
  7. This violates the contract that all writes must be flushed before the step completes

Expected behavior: Per the docstring at lines 265-266 and 293-294, polling should only resolve after the condition is stable for "multiple consecutive polls (to avoid race conditions with the pump)." The test file at lines 1002-1003 and 1084-1090 confirms this behavior by using STABLE_POLL_COUNT = 2 - only resolving after the unlock condition is seen 2 consecutive times.

Root cause: The actual implementation was missing the stability counter logic that the docstring promised and that the test expected. Added STABLE_POLL_COUNT constant and stability tracking to both polling functions.

Evidence:

  • Docstring promises: "for multiple consecutive polls (to avoid race conditions with the pump)"
  • Test implementation (lines 1084-1090): Implements stability counter that resets when condition changes
  • Test comment (line 1001): "mirrors the actual implementation"
  • Git history: Commit e8c3800 added polling with docstring but without stability logic

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@TooTallNate@VaguelySerious
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Fix stream serialization to resolve when user releases lock instead of waiting for stream to close - #661

Closed
TooTallNate wants to merge 2 commits into
mainfrom
12-20-fix_stream_serialization_to_resolve_when_user_releases_lock_instead_of_waiting_for_stream_to_close
Closed

Fix stream serialization to resolve when user releases lock instead of waiting for stream to close#661
TooTallNate wants to merge 2 commits into
mainfrom
12-20-fix_stream_serialization_to_resolve_when_user_releases_lock_instead_of_waiting_for_stream_to_close

Conversation

@TooTallNate

@TooTallNateTooTallNate commented Dec 20, 2025

Copy link
Copy Markdown
Member

Fix stream serialization to resolve when users release locks instead of waiting for streams to close, preventing Vercel functions from hanging.

What changed?

  • Implemented a polling mechanism to detect when stream locks are released
  • Added flushablePipe function that resolves in two scenarios:
    1. When the stream completes normally (close/error)
    2. When the user releases their lock AND all pending writes are flushed
  • Created a state tracking system to monitor pending operations and lock status

How to test?

  1. Create a workflow step that incrementally writes to a stream:

    constwriter=stream.getWriter();awaitwriter.write(data1);writer.releaseLock();// Step should complete here without waiting
  2. Verify the step completes immediately after lock release rather than hanging

  3. Run the new test cases that verify both lock release and normal stream closure behaviors

Why make this change?

This fixes an issue where Vercel functions would hang when users incrementally write to streams within steps. Previously, the system would wait for the stream to fully close before resolving, but many users follow a pattern where they write data and release the lock without explicitly closing the stream. This change allows steps to complete as soon as the user releases the lock and all pending writes are flushed, which is the expected behavior in most streaming scenarios.

@changeset-bot

changeset-botBot commented Dec 20, 2025

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 579162a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 12 packages
NameType
@workflow/corePatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/web-sharedPatch
workflowPatch
@workflow/astroPatch
@workflow/sveltekitPatch
@workflow/world-testingPatch
@workflow/nuxtPatch
@workflow/aiPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actionsBot commented Dec 20, 2025

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Starter🥇 Next.js (Turbopack)0.039s (+0.5%)1.013s (~)0.974s101.00x
💻 LocalNext.js (Turbopack)0.040s (-2.0%)1.017s (~)0.977s101.03x
💻 LocalNitro0.042s (-0.7%)1.007s (~)0.965s101.08x
🌐 RedisNext.js (Turbopack)0.042s (+0.7%)1.017s (~)0.975s101.08x
💻 LocalExpress0.043s (~)1.008s (~)0.965s101.11x
🌐 TursoNext.js (Turbopack)0.071s (-28.1% 🟢)1.014s (~)0.943s101.84x
🌐 MongoDBNext.js (Turbopack)0.107s (+44.9% 🔺)1.014s (~)0.907s102.76x
🐘 PostgresNext.js (Turbopack)0.128s (-21.4% 🟢)1.018s (~)0.890s103.31x
🐘 PostgresNitro0.343s (+13.9% 🔺)1.012s (~)0.669s108.87x
🐘 PostgresExpress0.369s (+16.2% 🔺)1.014s (~)0.645s109.54x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express0.552s (-9.8% 🟢)1.429s (-2.2%)0.877s101.00x
▲ VercelNext.js (Turbopack)0.556s (-12.4% 🟢)1.526s (-3.7%)0.970s101.01x
▲ VercelNitro0.569s (+8.1% 🔺)1.522s (-23.2% 🟢)0.953s101.03x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Starter🥇 Next.js (Turbopack)1.095s (+0.6%)2.009s (~)0.914s101.00x
💻 LocalNext.js (Turbopack)1.095s (~)2.012s (~)0.916s101.00x
🌐 RedisNext.js (Turbopack)1.097s (~)2.012s (~)0.915s101.00x
💻 LocalNitro1.110s (~)2.006s (~)0.896s101.01x
💻 LocalExpress1.112s (~)2.007s (~)0.895s101.02x
🌐 TursoNext.js (Turbopack)1.293s (-0.8%)2.012s (~)0.719s101.18x
🌐 MongoDBNext.js (Turbopack)1.309s (+0.8%)2.012s (~)0.703s101.20x
🐘 PostgresNitro2.196s (+2.5%)3.013s (~)0.817s102.01x
🐘 PostgresExpress2.207s (+1.9%)3.014s (~)0.807s102.02x
🐘 PostgresNext.js (Turbopack)2.458s (+27.9% 🔺)3.017s (+49.6% 🔺)0.559s102.25x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.619s (-2.6%)3.592s (-2.9%)0.973s101.00x
▲ VercelExpress2.626s (-1.6%)3.502s (-1.2%)0.876s101.00x
▲ VercelNitro2.669s (-2.4%)3.624s (-2.0%)0.955s101.02x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Starter🥇 Next.js (Turbopack)10.588s (~)11.013s (~)0.425s51.00x
💻 LocalNext.js (Turbopack)10.632s (~)11.016s (~)0.384s51.00x
🌐 RedisNext.js (Turbopack)10.678s (~)11.021s (~)0.342s51.01x
💻 LocalExpress10.781s (~)11.014s (~)0.234s51.02x
💻 LocalNitro10.787s (~)11.013s (~)0.226s51.02x
🌐 TursoNext.js (Turbopack)12.192s (~)13.020s (~)0.828s51.15x
🌐 MongoDBNext.js (Turbopack)12.197s (~)13.019s (~)0.822s51.15x
🐘 PostgresExpress16.081s (-21.2% 🟢)16.837s (-20.0% 🟢)0.756s51.52x
🐘 PostgresNitro18.950s (-6.9% 🟢)19.634s (-5.8% 🟢)0.683s51.79x
🐘 PostgresNext.js (Turbopack)20.287s (+33.3% 🔺)21.046s (+31.2% 🔺)0.760s51.92x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro21.714s (+0.7%)22.834s (+1.8%)1.120s51.00x
▲ VercelExpress21.984s (+1.6%)23.013s (+3.2%)1.029s51.01x
▲ VercelNext.js (Turbopack)22.185s (+1.8%)23.116s (+2.5%)0.932s51.02x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Starter🥇 Next.js (Turbopack)1.334s (~)2.007s (~)0.673s151.00x
🌐 RedisNext.js (Turbopack)1.365s (+0.6%)2.011s (~)0.646s151.02x
💻 LocalNext.js (Turbopack)1.373s (-1.6%)2.010s (~)0.636s151.03x
💻 LocalNitro1.408s (~)2.005s (~)0.597s151.06x
💻 LocalExpress1.408s (~)2.006s (~)0.598s151.06x
🐘 PostgresNext.js (Turbopack)1.901s (+3.1%)2.164s (+4.7%)0.263s141.43x
🐘 PostgresExpress1.939s (-18.7% 🟢)2.225s (-26.1% 🟢)0.287s141.45x
🌐 MongoDBNext.js (Turbopack)2.130s (~)3.011s (~)0.881s101.60x
🌐 TursoNext.js (Turbopack)2.198s (~)3.014s (~)0.815s101.65x
🐘 PostgresNitro2.351s (~)2.921s (-3.0%)0.569s111.76x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.844s (+4.8%)3.777s (-0.9%)0.933s81.00x
▲ VercelNext.js (Turbopack)2.994s (-1.9%)3.767s (-5.3% 🟢)0.774s81.05x
▲ VercelExpress3.634s (+32.8% 🔺)4.564s (+29.8% 🔺)0.930s71.28x

🔍 Observability: Nitro | Next.js (Turbopack) | Express

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)2.081s (-3.8%)2.911s (-6.6% 🟢)0.830s111.00x
💻 LocalExpress2.206s (~)3.160s (~)0.954s101.06x
💻 LocalNitro2.224s (~)3.169s (~)0.945s101.07x
🌐 StarterNext.js (Turbopack)2.448s (~)3.009s (~)0.560s101.18x
🌐 RedisNext.js (Turbopack)2.506s (+0.5%)3.012s (~)0.507s101.20x
🐘 PostgresExpress2.704s (-7.4% 🟢)3.016s (-6.8% 🟢)0.312s101.30x
🐘 PostgresNitro2.713s (+2.2%)3.012s (-0.5%)0.298s101.30x
🐘 PostgresNext.js (Turbopack)2.920s (+10.9% 🔺)3.411s (+12.5% 🔺)0.491s91.40x
🌐 MongoDBNext.js (Turbopack)4.685s (-1.8%)5.180s (~)0.495s62.25x
🌐 TursoNext.js (Turbopack)4.704s (+0.7%)5.178s (~)0.474s62.26x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.558s (+16.9% 🔺)4.236s (+15.0% 🔺)0.678s81.00x
▲ VercelNext.js (Turbopack)3.664s (+15.3% 🔺)4.434s (+16.2% 🔺)0.770s71.03x
▲ VercelNitro3.779s (+21.7% 🔺)4.309s (+12.4% 🔺)0.530s91.06x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Starter🥇 Next.js (Turbopack)1.350s (-1.6%)2.007s (~)0.656s151.00x
🌐 RedisNext.js (Turbopack)1.371s (-1.1%)2.009s (~)0.638s151.02x
💻 LocalNext.js (Turbopack)1.388s (-0.7%)2.013s (~)0.625s151.03x
💻 LocalExpress1.408s (~)2.006s (~)0.598s151.04x
💻 LocalNitro1.431s (~)2.005s (~)0.574s151.06x
🐘 PostgresExpress1.706s (-3.4%)2.009s (~)0.303s151.26x
🐘 PostgresNitro1.775s (-5.3% 🟢)2.011s (~)0.236s151.31x
🐘 PostgresNext.js (Turbopack)2.026s (+24.5% 🔺)2.231s (+10.8% 🔺)0.206s141.50x
🌐 MongoDBNext.js (Turbopack)2.176s (+2.7%)3.011s (~)0.835s101.61x
🌐 TursoNext.js (Turbopack)2.233s (+0.7%)3.015s (~)0.782s101.65x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.872s (+7.4% 🔺)3.757s (~)0.885s81.00x
▲ VercelExpress2.879s (+6.2% 🔺)4.136s (+17.9% 🔺)1.257s81.00x
▲ VercelNext.js (Turbopack)2.974s (+11.8% 🔺)4.393s (+20.0% 🔺)1.419s71.04x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)2.056s (-10.4% 🟢)2.916s (-10.1% 🟢)0.860s111.00x
💻 LocalExpress2.221s (-1.0%)3.152s (-1.1%)0.931s101.08x
💻 LocalNitro2.249s (-5.9% 🟢)3.178s (-4.6%)0.929s101.09x
🌐 StarterNext.js (Turbopack)2.458s (~)3.010s (~)0.552s101.20x
🐘 PostgresExpress2.502s (-8.1% 🟢)3.017s (~)0.515s101.22x
🌐 RedisNext.js (Turbopack)2.508s (+0.8%)3.012s (~)0.504s101.22x
🐘 PostgresNext.js (Turbopack)2.734s (+3.0%)3.028s (~)0.294s101.33x
🐘 PostgresNitro2.791s (+19.2% 🔺)3.038s (+0.9%)0.246s101.36x
🌐 TursoNext.js (Turbopack)4.693s (-1.6%)5.181s (~)0.488s62.28x
🌐 MongoDBNext.js (Turbopack)4.748s (+0.9%)5.179s (~)0.431s62.31x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.181s (+7.2% 🔺)3.741s (+1.6%)0.559s91.00x
▲ VercelExpress3.352s (+4.0%)3.953s (+3.9%)0.601s81.05x
▲ VercelNext.js (Turbopack)3.540s (+10.7% 🔺)4.341s (+11.9% 🔺)0.800s71.11x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🌐 Starter🥇 Next.js (Turbopack)0.125s (-3.2%)1.005s (~)0.000s (+Infinity% 🔺)1.010s (~)0.885s101.00x
💻 LocalNext.js (Turbopack)0.135s (-8.2% 🟢)1.003s (~)0.019s (+14.1% 🔺)1.029s (~)0.895s101.08x
🌐 RedisNext.js (Turbopack)0.141s (-1.1%)1.005s (~)0.000s (+Infinity% 🔺)1.013s (~)0.872s101.13x
💻 LocalNitro0.176s (~)0.992s (~)0.015s (-6.9% 🟢)1.021s (~)0.845s101.41x
💻 LocalExpress0.176s (~)0.992s (~)0.016s (~)1.022s (~)0.846s101.41x
🌐 TursoNext.js (Turbopack)0.461s (-5.9% 🟢)0.985s (+2.5%)0.000s (~)1.012s (~)0.551s103.69x
🌐 MongoDBNext.js (Turbopack)0.523s (+1.9%)0.925s (-1.1%)0.000s (+Infinity% 🔺)1.013s (~)0.490s104.18x
🐘 PostgresExpress1.353s (-41.6% 🟢)1.687s (-38.1% 🟢)0.000s (+200.0% 🔺)2.012s (-33.3% 🟢)0.659s1010.81x
🐘 PostgresNitro1.370s (-39.7% 🟢)1.670s (-39.7% 🟢)0.000s (-100.0% 🟢)2.012s (-33.2% 🟢)0.642s1010.95x
🐘 PostgresNext.js (Turbopack)1.608s (+35.8% 🔺)2.296s (+34.4% 🔺)0.000s (+Infinity% 🔺)2.418s (+32.9% 🔺)0.811s1012.85x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.631s (-11.4% 🟢)3.110s (-3.9%)0.657s (-8.1% 🟢)4.223s (-6.0% 🟢)1.592s101.00x
▲ VercelNitro2.770s (+4.7%)3.322s (+3.1%)0.567s (-15.4% 🟢)4.412s (+2.5%)1.643s101.05x
▲ VercelNext.js (Turbopack)2.788s (+6.0% 🔺)3.253s (+1.8%)0.832s (+45.0% 🔺)4.510s (+7.5% 🔺)1.722s101.06x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNext.js (Turbopack)8/8
🐘 PostgresExpress5/8
▲ VercelNitro4/8
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express💻 Local8/8
Next.js (Turbopack)🌐 Starter6/8
Nitro💻 Local8/8
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Starter: Community world (local development)
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)

📋 View full workflow run

@github-actions

github-actionsBot commented Dec 20, 2025

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production286011297
✅ 💻 Local Development26208270
✅ 📦 Local Production26208270
✅ 🐘 Local Postgres26208270
✅ 🪟 Windows270027
❌ 🌍 Community Worlds109110120
Total120811351254

❌ Failed Tests

🌍 Community Worlds (11 failed)

mongodb (1 failed):

  • webhookWorkflow

redis (1 failed):

  • webhookWorkflow

starter (8 failed):

  • addTenWorkflow
  • addTenWorkflow
  • retryAttemptCounterWorkflow
  • crossFileErrorWorkflow - stack traces work across imported modules
  • hookCleanupTestWorkflow - hook token reuse after workflow completion
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step

turso (1 failed):

  • webhookWorkflow

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro2601
✅ example2601
✅ express2601
✅ fastify2601
✅ hono2601
✅ nextjs-turbopack2601
✅ nextjs-webpack2601
✅ nitro2601
✅ nuxt2601
✅ sveltekit2601
✅ vite2601
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable2601
✅ express-stable2601
✅ fastify-stable2601
✅ hono-stable2601
✅ nextjs-turbopack-stable2700
✅ nextjs-webpack-stable2700
✅ nitro-stable2601
✅ nuxt-stable2601
✅ sveltekit-stable2601
✅ vite-stable2601
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable2601
✅ express-stable2601
✅ fastify-stable2601
✅ hono-stable2601
✅ nextjs-turbopack-stable2700
✅ nextjs-webpack-stable2700
✅ nitro-stable2601
✅ nuxt-stable2601
✅ sveltekit-stable2601
✅ vite-stable2601
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable2601
✅ express-stable2601
✅ fastify-stable2601
✅ hono-stable2601
✅ nextjs-turbopack-stable2700
✅ nextjs-webpack-stable2700
✅ nitro-stable2601
✅ nuxt-stable2601
✅ sveltekit-stable2601
✅ vite-stable2601
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack2700
❌ 🌍 Community Worlds
AppPassedFailedSkipped
✅ mongodb-dev300
❌ mongodb2610
✅ redis-dev300
❌ redis2610
✅ starter-dev300
❌ starter1980
✅ turso-dev300
❌ turso2610

📋 View full workflow run

@vercel

vercelBot commented Dec 20, 2025

Copy link
Copy Markdown
Contributor

@TooTallNateGraphite App

TooTallNate commented Dec 20, 2025

Copy link
Copy Markdown
MemberAuthor

Comment on lines +356 to +362
} catch (err) {
state.streamEnded = true;
if (!state.doneResolved) {
state.doneResolved = true;
state.reject(err);
}
throw err;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stream locks are not released when an error occurs in the pipe. The error is thrown without releasing the locks acquired at the start of the function.

View Details
📝 Patch Details
diff --git a/packages/core/src/serialization.ts b/packages/core/src/serialization.ts
index c8ebc73..23e2ae6 100644
--- a/packages/core/src/serialization.ts+++ b/packages/core/src/serialization.ts@@ -359,6 +359,8 @@ async function flushablePipe(
state.doneResolved = true;
state.reject(err);
}
+ reader.releaseLock();+ writer.releaseLock();
throw err;
}
}

Analysis

Stream locks not released when error occurs in flushablePipe

What fails: The flushablePipe() function in packages/core/src/serialization.ts does not release the reader and writer locks when an error is thrown in the catch block (lines 356-362), leaving the streams in a permanently locked state.

How to reproduce: Create a test scenario where an error occurs during pipe operation:

// Create a readable streamconstsource=newReadableStream({start(controller){controller.enqueue('chunk1');controller.enqueue('chunk2');controller.close();}});// Create a writable stream that errors on second writeletwriteCount=0;constsink=newWritableStream({asyncwrite(chunk){writeCount++;if(writeCount===2){thrownewError('Write failed!');}}});// Call flushablePipe with error stateconststate={pendingOps: 0,doneResolved: false,streamEnded: false,resolve: ()=>{},reject: ()=>{}};try{awaitflushablePipe(source,sink,state);}catch(err){// Error caught}// Attempt to acquire new reader/writersource.getReader();// Throws: "InvalidStateError: ReadableStream is locked"sink.getWriter();// Throws: "InvalidStateError: WritableStream is locked"

Result: Streams remain locked (source.locked === true, sink.locked === true), preventing new readers/writers from being obtained. Cannot acquire new reader: "InvalidStateError: ReadableStream is locked". Cannot acquire new writer: "InvalidStateError: WritableStream is locked".

Expected behavior: The locks should be released before throwing the error, as they are in normal code paths (lines 326, 339, 346). Per WHATWG Streams API specification, a lock must be explicitly released, and a locked stream prevents other readers/writers from being obtained. Leaving locks unreleased creates a resource leak that prevents stream reuse.

Root cause: The catch block at lines 356-362 sets error state and throws without calling reader.releaseLock() and writer.releaseLock(), unlike all other exit paths from the function which properly release locks.

Fix applied: Added reader.releaseLock() and writer.releaseLock() before the throw err statement in the catch block to match the cleanup pattern used in normal code paths.

Comment on lines +329 to +338
if (readResult.done) {
// Source stream completed - close sink and resolve
state.streamEnded = true;
await writer.close();
// Resolve done promise if not already resolved
if (!state.doneResolved) {
state.doneResolved = true;
state.resolve();
}
return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stream locks are not released when the source stream ends. The flushablePipe function acquires locks on the reader and writer but doesn't release them before returning when the readable stream completes.

View Details
📝 Patch Details
diff --git a/packages/core/src/serialization.ts b/packages/core/src/serialization.ts
index c8ebc73..57e4750 100644
--- a/packages/core/src/serialization.ts+++ b/packages/core/src/serialization.ts@@ -335,6 +335,8 @@ async function flushablePipe(
state.doneResolved = true;
state.resolve();
}
+ reader.releaseLock();+ writer.releaseLock();
return;
}
@@ -359,6 +361,8 @@ async function flushablePipe(
state.doneResolved = true;
state.reject(err);
}
+ reader.releaseLock();+ writer.releaseLock();
throw err;
}
}

Analysis

Stream locks not released in flushablePipe() when source completes or errors

What fails: The flushablePipe() function in packages/core/src/serialization.ts (lines 333-341 and 365-371) acquires locks on ReadableStream and WritableStream but fails to release them before returning when the stream completes or when an error occurs.

How to reproduce:

// When readResult.done is true (source stream completed):constreadResult=awaitreader.read();if(readResult.done){state.streamEnded=true;awaitwriter.close();// ... resolve promise ...return;// ❌ Returns without releasing locks}// Or when error occurs:}catch(err){state.streamEnded=true;// ... reject promise ...throwerr;// ❌ Throws without releasing locks}

Result: Unreleased locks prevent any other code from acquiring new readers or writers on these streams. Subsequent attempts to call getReader() or getWriter() will throw TypeError: ReadableStreamDefaultReader constructor can only accept readable streams that are not yet locked to a reader. The stream becomes permanently inaccessible.

Expected behavior: According to MDN Web JavaScript resource management, unreleased locks cause the stream to remain locked indefinitely and "does not permit another reader to consume it." The locks must be explicitly released via reader.releaseLock() and writer.releaseLock() before the function exits.

Fix: Added reader.releaseLock() and writer.releaseLock() calls before all return/throw statements in the flushablePipe function:

  • Before returning when readResult.done (normal stream completion)
  • Before throwing in the catch block (error case)

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements a polling mechanism to detect when stream locks are released, allowing workflow steps to complete without waiting for streams to fully close. This fixes an issue where Vercel functions would hang when users write to streams and release locks without explicitly closing them.

Key Changes:

  • Added flushablePipe function that resolves when either the stream completes or when the user releases their lock with all pending writes flushed
  • Implemented polling functions (pollWritableLock and pollReadableLock) that check stream lock state every 100ms
  • Created state tracking system (FlushableStreamState) to monitor pending operations and resolution status

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 15 comments.

FileDescription
packages/core/src/serialization.tsImplements the core flushable pipe mechanism with polling-based lock detection and state tracking for both readable and writable streams
packages/core/src/serialization.test.tsAdds tests verifying the new behavior for both lock release and natural stream closure scenarios
.changeset/stream-lock-polling.mdDocuments the patch-level change for the @workflow/core package

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +1174 to +1176
flushablePipe(readable, transform.writable, state).catch(() => {
// Errors are handled via state.reject
});

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The flushablePipe function throws errors after calling state.reject() (line 405), but all call sites catch and ignore these thrown errors with empty catch blocks (e.g., line 976, 992, 1016). This pattern is confusing because the error is both rejected through the state AND thrown. Since the error is already propagated via state.reject() which rejects the 'done' promise, the re-throw serves no purpose and the empty catch blocks make this unclear. Consider either removing the throw from flushablePipe or documenting why the error needs to be both rejected and thrown.

Copilot uses AI. Check for mistakes.
Comment on lines +399 to +406
} catch (err) {
state.streamEnded = true;
if (!state.doneResolved) {
state.doneResolved = true;
state.reject(err);
}
throw err;
}

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error handling in the flushablePipe function could potentially leak resources. When an exception occurs in the catch block at line 399, the reader and writer locks are not released before the error is re-thrown. This could leave the streams in a locked state. Consider releasing the locks in the catch block before re-throwing.

Copilot uses AI. Check for mistakes.
Comment on lines +229 to +261
/**
* Checks if a WritableStream is unlocked (user released lock) vs closed.
* When a stream is closed, .locked is false but getWriter() throws.
* We only want to resolve via polling when the stream is unlocked, not closed.
* If closed, the pump will handle resolution via the stream ending naturally.
*/
function isWritableUnlockedNotClosed(writable: WritableStream): boolean {
if (writable.locked) return false;

try {
// Try to acquire writer - if successful, stream is unlocked (not closed)
const writer = writable.getWriter();
writer.releaseLock();
return true;
} catch {
// getWriter() throws if stream is closed/errored - let pump handle it
return false;
}
}

/**
* Checks if a ReadableStream is unlocked (user released lock) vs closed.
*/
function isReadableUnlockedNotClosed(readable: ReadableStream): boolean {
if (readable.locked) return false;

try {
// Try to acquire reader - if successful, stream is unlocked (not closed)
const reader = readable.getReader();
reader.releaseLock();
return true;
} catch {
// getReader() throws if stream is closed/errored - let pump handle it

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The polling functions acquire and release stream locks every 100ms to check if a stream is unlocked vs closed. This repeated lock acquisition could have performance implications and might interfere with stream operations. Consider using a different approach to detect stream closure, such as checking for specific error messages or using a flag that's set when the stream closes, to avoid repeatedly acquiring locks during polling.

Suggested change
/**
*ChecksifaWritableStreamisunlocked(userreleasedlock)vsclosed.
*Whenastreamisclosed,.lockedisfalsebutgetWriter()throws.
*Weonlywanttoresolveviapollingwhenthestreamisunlocked,notclosed.
*Ifclosed,thepumpwillhandleresolutionviathestreamendingnaturally.
*/
functionisWritableUnlockedNotClosed(writable: WritableStream): boolean{
if(writable.locked)returnfalse;
try{
// Try to acquire writer - if successful, stream is unlocked (not closed)
constwriter=writable.getWriter();
writer.releaseLock();
returntrue;
}catch{
// getWriter() throws if stream is closed/errored - let pump handle it
returnfalse;
}
}
/**
*ChecksifaReadableStreamisunlocked(userreleasedlock)vsclosed.
*/
functionisReadableUnlockedNotClosed(readable: ReadableStream): boolean{
if(readable.locked)returnfalse;
try{
// Try to acquire reader - if successful, stream is unlocked (not closed)
constreader=readable.getReader();
reader.releaseLock();
returntrue;
}catch{
// getReader() throws if stream is closed/errored - let pump handle it
/**
*Cacheofper-stream"unlocked but not closed"statusforWritableStreams.
*Thisavoidsrepeatedlyacquiringandreleasinglocksduringpolling;we
*probethestreamatmostonceperinstancewhenittransitionstounlocked.
*/
constwritableUnlockedNotClosedCache=newWeakMap<WritableStream,boolean>();
/**
*ChecksifaWritableStreamisunlocked(userreleasedlock)vsclosed.
*Whenastreamisclosed,.lockedisfalsebutgetWriter()throws.
*Weonlywanttoresolveviapollingwhenthestreamisunlocked,notclosed.
*Ifclosed,thepumpwillhandleresolutionviathestreamendingnaturally.
*
*Toavoidrepeatedlockacquisitionduringpolling,wecachetheresultof
*theunlocked-vs-closedprobeperstream.Oncedetermined,thestatus is
*reusedonsubsequentcallsforthesamestream.
*/
functionisWritableUnlockedNotClosed(writable: WritableStream): boolean{
if(writable.locked)returnfalse;
constcached=writableUnlockedNotClosedCache.get(writable);
if(cached!==undefined){
returncached;
}
try{
// Try to acquire writer - if successful, stream is unlocked (not closed)
constwriter=writable.getWriter();
writer.releaseLock();
writableUnlockedNotClosedCache.set(writable,true);
returntrue;
}catch{
// getWriter() throws if stream is closed/errored - let pump handle it
writableUnlockedNotClosedCache.set(writable,false);
returnfalse;
}
}
/**
*Cacheofper-stream"unlocked but not closed"statusforReadableStreams.
*Likethewritablecache,thispreventsrepeatedreaderacquisitionswhile
*pollingforlockrelease.
*/
constreadableUnlockedNotClosedCache=newWeakMap<ReadableStream,boolean>();
/**
*ChecksifaReadableStreamisunlocked(userreleasedlock)vsclosed.
*Usesaper-streamcachetoavoidrepeatedlyacquiringandreleasinglocks
*duringpolling.
*/
functionisReadableUnlockedNotClosed(readable: ReadableStream): boolean{
if(readable.locked)returnfalse;
constcached=readableUnlockedNotClosedCache.get(readable);
if(cached!==undefined){
returncached;
}
try{
// Try to acquire reader - if successful, stream is unlocked (not closed)
constreader=readable.getReader();
reader.releaseLock();
readableUnlockedNotClosedCache.set(readable,true);
returntrue;
}catch{
// getReader() throws if stream is closed/errored - let pump handle it
readableUnlockedNotClosedCache.set(readable,false);

Copilot uses AI. Check for mistakes.
Comment on lines +1179 to +1186
} catch (err) {
state.streamEnded = true;
if (!state.doneResolved) {
state.doneResolved = true;
state.reject(err as Error);
}
}
})();

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error handling in the test's pipe implementation (line 1179-1185) doesn't release the reader and writer locks before exiting, similar to the issue in the actual flushablePipe implementation. While this mirrors the production code, it would be better to release the locks in the catch block to properly clean up resources. Consider updating the test to match the corrected implementation once the production code is fixed.

Copilot uses AI. Check for mistakes.
Comment on lines +1016 to +1018
flushablePipe(serialize.readable, serverWritable, state).catch(() => {
// Errors are handled via state.reject
});

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The flushablePipe function throws errors after calling state.reject() (line 405), but all call sites catch and ignore these thrown errors with empty catch blocks (e.g., line 976, 992, 1016). This pattern is confusing because the error is both rejected through the state AND thrown. Since the error is already propagated via state.reject() which rejects the 'done' promise, the re-throw serves no purpose and the empty catch blocks make this unclear. Consider either removing the throw from flushablePipe or documenting why the error needs to be both rejected and thrown.

Suggested change
flushablePipe(serialize.readable,serverWritable,state).catch(()=>{
// Errors are handled via state.reject
});
flushablePipe(serialize.readable,serverWritable,state).catch(
(error)=>{
// flushablePipe already calls state.reject(error), which rejects the
// `done` promise added to `ops`. We catch and intentionally ignore
// this rejection here only to avoid an unhandled promise rejection;
// all real error handling flows through state.done.
}
);

Copilot uses AI. Check for mistakes.
Comment on lines +1001 to +1229
describe('flushable stream behavior', () => {
const POLL_INTERVAL = 100; // Match the actual implementation
const STABLE_POLL_COUNT = 2; // Match the actual implementation

it('done promise should resolve when writable stream lock is released (polling)', async () => {
// Test the pattern: user writes, releases lock, polling detects it, done resolves
const chunks: string[] = [];
let streamClosed = false;

// Create a simple mock for the sink
const mockSink = new WritableStream<string>({
write(chunk) {
chunks.push(chunk);
},
close() {
streamClosed = true;
},
});

// Create a TransformStream like we do in getStepRevivers
const { readable, writable } = new TransformStream<string, string>();

// Track flushable state - this mirrors the actual implementation
const state = {
pendingOps: 0, // Only counts writes to server
doneResolved: false,
streamEnded: false,
resolve: () => {},
reject: (_err: Error) => {},
};

const done = new Promise<void>((res, rej) => {
state.resolve = res;
state.reject = rej;
});

// Start piping in background (mirrors flushablePipe implementation)
(async () => {
const reader = readable.getReader();
const writer = mockSink.getWriter();
try {
while (!state.streamEnded) {
const result = await reader.read();

if (result.done) {
state.streamEnded = true;
await writer.close();
if (!state.doneResolved) {
state.doneResolved = true;
state.resolve();
}
return;
}

// Only writes count as pending ops
state.pendingOps++;
await writer.write(result.value);
state.pendingOps--;

if (state.streamEnded) {
reader.releaseLock();
writer.releaseLock();
return;
}
}
} catch (err) {
state.streamEnded = true;
if (!state.doneResolved) {
state.doneResolved = true;
state.reject(err as Error);
}
}
})();

// Start polling (mirrors pollWritableLock implementation)
let stableCount = 0;
const intervalId = setInterval(() => {
if (state.doneResolved || state.streamEnded) {
clearInterval(intervalId);
return;
}

// Check if lock is released by checking .locked property
if (!writable.locked && state.pendingOps === 0) {
stableCount++;
if (stableCount >= STABLE_POLL_COUNT) {
state.doneResolved = true;
state.resolve();
clearInterval(intervalId);
}
} else {
stableCount = 0;
}
}, POLL_INTERVAL);

// Simulate user interaction - write and release lock
const userWriter = writable.getWriter();
await userWriter.write('chunk1');
await userWriter.write('chunk2');

// Release lock without closing stream
userWriter.releaseLock();

// Wait for pipe to process + polling intervals (need STABLE_POLL_COUNT consecutive polls)
await new Promise((r) => setTimeout(r, 250));

// The done promise should resolve
await expect(
Promise.race([
done,
new Promise((_, r) => setTimeout(() => r(new Error('timeout')), 400)),
])
).resolves.toBeUndefined();

// Chunks should have been written
expect(chunks).toContain('chunk1');
expect(chunks).toContain('chunk2');

// Stream should NOT be closed (user only released lock)
expect(streamClosed).toBe(false);
});

it('done promise should resolve when writable stream closes naturally', async () => {
const chunks: string[] = [];
let streamClosed = false;

const mockSink = new WritableStream<string>({
write(chunk) {
chunks.push(chunk);
},
close() {
streamClosed = true;
},
});

const { readable, writable } = new TransformStream<string, string>();

const state = {
pendingOps: 0,
doneResolved: false,
streamEnded: false,
resolve: () => {},
reject: (_err: Error) => {},
};

const done = new Promise<void>((res, rej) => {
state.resolve = res;
state.reject = rej;
});

// Start piping in background
(async () => {
const reader = readable.getReader();
const writer = mockSink.getWriter();
try {
while (!state.streamEnded) {
const result = await reader.read();

if (result.done) {
state.streamEnded = true;
await writer.close();
if (!state.doneResolved) {
state.doneResolved = true;
state.resolve();
}
return;
}

state.pendingOps++;
await writer.write(result.value);
state.pendingOps--;

if (state.streamEnded) {
reader.releaseLock();
writer.releaseLock();
return;
}
}
} catch (err) {
state.streamEnded = true;
if (!state.doneResolved) {
state.doneResolved = true;
state.reject(err as Error);
}
}
})();

// Start polling (won't trigger since stream will close first)
let stableCount2 = 0;
const intervalId = setInterval(() => {
if (state.doneResolved || state.streamEnded) {
clearInterval(intervalId);
return;
}
if (!writable.locked && state.pendingOps === 0) {
stableCount2++;
if (stableCount2 >= STABLE_POLL_COUNT) {
state.doneResolved = true;
state.resolve();
clearInterval(intervalId);
}
} else {
stableCount2 = 0;
}
}, POLL_INTERVAL);

// User writes and then closes the stream
const userWriter = writable.getWriter();
await userWriter.write('data');
await userWriter.close();

// Wait a tick for the pipe to process
await new Promise((r) => setTimeout(r, 50));

// The done promise should resolve
await expect(
Promise.race([
done,
new Promise((_, r) => setTimeout(() => r(new Error('timeout')), 200)),
])
).resolves.toBeUndefined();

// Chunks should have been written
expect(chunks).toContain('data');

// Stream should be closed (user closed it)
expect(streamClosed).toBe(true);
});
});

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new flushable stream behavior tests only cover the happy paths (lock release and normal closure). Consider adding test cases for error scenarios such as: stream errors during write operations, reader errors, and what happens when the stream is aborted. This would help ensure the error handling in flushablePipe and the polling mechanisms work correctly under failure conditions.

Copilot uses AI. Check for mistakes.
Comment on lines +1158 to +1160
flushablePipe(readable, writable, state).catch(() => {
// Errors are handled via state.reject
});

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The flushablePipe function throws errors after calling state.reject() (line 405), but all call sites catch and ignore these thrown errors with empty catch blocks (e.g., line 976, 992, 1016). This pattern is confusing because the error is both rejected through the state AND thrown. Since the error is already propagated via state.reject() which rejects the 'done' promise, the re-throw serves no purpose and the empty catch blocks make this unclear. Consider either removing the throw from flushablePipe or documenting why the error needs to be both rejected and thrown.

Copilot uses AI. Check for mistakes.
Comment on lines +192 to +196
* - `doneResolved`: The `done` promise has been resolved (step can complete)
* - `streamEnded`: The underlying stream has actually closed/errored
*
* The pump continues running even after `doneResolved=true` to handle
* any future writes if the user acquires a new lock.

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment states that "The pump continues running even after doneResolved=true to handle any future writes if the user acquires a new lock." However, this design could be problematic. Once the done promise resolves (pushed to ops), the workflow step will complete. If the user then acquires a new lock and writes more data after the step has completed, it could lead to unexpected behavior where data is being written to a stream associated with an already-completed step. Consider whether this is the intended behavior or if the stream should be closed/locked after the done promise resolves.

Suggested change
*-`doneResolved`: The`done`promisehasbeenresolved(stepcancomplete)
*-`streamEnded`: Theunderlyingstreamhasactuallyclosed/errored
*
*Thepumpcontinuesrunningevenafter`doneResolved=true`tohandle
* any futurewritesiftheuseracquiresanewlock.
*-`doneResolved`: The`done`promisehasbeenresolved(stepcancomplete),
*andnofurtherwritesshouldbeacceptedforthisstream.
*-`streamEnded`: Theunderlyingstreamhasactuallyclosed/errored.
*
*Once`doneResolved`becomes`true`,thepumpshouldonlydrainany
*already-pendingoperationsandthenstop;itmustnothandlenewwrites
*fromsubsequentlyacquiredlocks.

Copilot uses AI. Check for mistakes.
Comment on lines +1066 to +1073
} catch (err) {
state.streamEnded = true;
if (!state.doneResolved) {
state.doneResolved = true;
state.reject(err as Error);
}
}
})();

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error handling in the test's pipe implementation (line 1066-1072) doesn't release the reader and writer locks before exiting, similar to the issue in the actual flushablePipe implementation. While this mirrors the production code, it would be better to release the locks in the catch block to properly clean up resources. Consider updating the test to match the corrected implementation once the production code is fixed.

Copilot uses AI. Check for mistakes.
Comment on lines +1189 to +1203
let stableCount2 = 0;
const intervalId = setInterval(() => {
if (state.doneResolved || state.streamEnded) {
clearInterval(intervalId);
return;
}
if (!writable.locked && state.pendingOps === 0) {
stableCount2++;
if (stableCount2 >= STABLE_POLL_COUNT) {
state.doneResolved = true;
state.resolve();
clearInterval(intervalId);
}
} else {
stableCount2 = 0;

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The variable name stableCount2 is unclear and suggests copy-paste from the first test. Consider renaming to stableCount for consistency, as the numbering doesn't add clarity and makes the code harder to read.

Suggested change
letstableCount2=0;
constintervalId=setInterval(()=>{
if(state.doneResolved||state.streamEnded){
clearInterval(intervalId);
return;
}
if(!writable.locked&&state.pendingOps===0){
stableCount2++;
if(stableCount2>=STABLE_POLL_COUNT){
state.doneResolved=true;
state.resolve();
clearInterval(intervalId);
}
}else{
stableCount2=0;
letstableCount=0;
constintervalId=setInterval(()=>{
if(state.doneResolved||state.streamEnded){
clearInterval(intervalId);
return;
}
if(!writable.locked&&state.pendingOps===0){
stableCount++;
if(stableCount>=STABLE_POLL_COUNT){
state.doneResolved=true;
state.resolve();
clearInterval(intervalId);
}
}else{
stableCount=0;

Copilot uses AI. Check for mistakes.

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before I review - there's a ton of AI comments on this PR and I want to give you a chance to resolve those, so I'm subscribing and re-reviewing after the next commit/message

Base automatically changed from 12-22-fix_race_conditions_in_webhook_response_handling to mainDecember 22, 2025 18:41
@TooTallNate

Copy link
Copy Markdown
MemberAuthor

@copilot Please evaluate the code review suggestions in this PR and apply them if you think they are relevent.

CopilotAI commented Dec 22, 2025

Copy link
Copy Markdown
Contributor

@TooTallNate I've opened a new pull request, #677, to work on those changes. Once the pull request is ready, I'll request review from you.

…f waiting for stream to close
This prevents Vercel functions from hanging when users incrementally write to streams within steps (e.g., `await writer.write(data); writer.releaseLock()`). Uses a polling approach to detect when the stream lock is released and all pending writes are flushed.
Comment on lines +271 to +316
function pollWritableLock(
writable: WritableStream,
state: FlushableStreamState
): void {
const intervalId = setInterval(() => {
// Stop polling if already resolved or stream ended
if (state.doneResolved || state.streamEnded) {
clearInterval(intervalId);
return;
}

// Check if lock is released (not closed) and no pending ops
if (isWritableUnlockedNotClosed(writable) && state.pendingOps === 0) {
state.doneResolved = true;
state.resolve();
clearInterval(intervalId);
}
}, LOCK_POLL_INTERVAL_MS);
}

/**
* Polls a ReadableStream to check if the user has released their lock.
* Resolves the done promise when lock is released and no pending ops remain.
*
* Note: Only resolves if stream is unlocked but NOT closed. If the user closes
* the stream, the pump will handle resolution via the stream ending naturally.
*/
function pollReadableLock(
readable: ReadableStream,
state: FlushableStreamState
): void {
const intervalId = setInterval(() => {
// Stop polling if already resolved or stream ended
if (state.doneResolved || state.streamEnded) {
clearInterval(intervalId);
return;
}

// Check if lock is released (not closed) and no pending ops
if (isReadableUnlockedNotClosed(readable) && state.pendingOps === 0) {
state.doneResolved = true;
state.resolve();
clearInterval(intervalId);
}
}, LOCK_POLL_INTERVAL_MS);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The polling functions don't implement the "multiple consecutive polls" behavior mentioned in the docstring, creating a potential race condition where the done promise resolves before the pump finishes writing queued data.

View Details
📝 Patch Details
diff --git a/packages/core/src/serialization.ts b/packages/core/src/serialization.ts
index 69c391b..376afe4 100644
--- a/packages/core/src/serialization.ts+++ b/packages/core/src/serialization.ts@@ -182,6 +182,9 @@ export class WorkflowServerWritableStream extends WritableStream<Uint8Array> {
/** Polling interval for checking stream lock state (in milliseconds) */
const LOCK_POLL_INTERVAL_MS = 100;
+/** Number of consecutive stable polls before resolving done promise */+const STABLE_POLL_COUNT = 2;+
/**
* State tracker for flushable stream operations.
* Resolves when either:
@@ -272,6 +275,7 @@ function pollWritableLock(
writable: WritableStream,
state: FlushableStreamState
): void {
+ let stableCount = 0;
const intervalId = setInterval(() => {
// Stop polling if already resolved or stream ended
if (state.doneResolved || state.streamEnded) {
@@ -281,16 +285,22 @@ function pollWritableLock(
// Check if lock is released (not closed) and no pending ops
if (isWritableUnlockedNotClosed(writable) && state.pendingOps === 0) {
- state.doneResolved = true;- state.resolve();- clearInterval(intervalId);+ stableCount++;+ if (stableCount >= STABLE_POLL_COUNT) {+ state.doneResolved = true;+ state.resolve();+ clearInterval(intervalId);+ }+ } else {+ stableCount = 0;
}
}, LOCK_POLL_INTERVAL_MS);
}
/**
* Polls a ReadableStream to check if the user has released their lock.
- * Resolves the done promise when lock is released and no pending ops remain.+ * Resolves the done promise when lock is released and no pending ops remain+ * for multiple consecutive polls (to avoid race conditions with the pump).
*
* Note: Only resolves if stream is unlocked but NOT closed. If the user closes
* the stream, the pump will handle resolution via the stream ending naturally.
@@ -299,6 +309,7 @@ function pollReadableLock(
readable: ReadableStream,
state: FlushableStreamState
): void {
+ let stableCount = 0;
const intervalId = setInterval(() => {
// Stop polling if already resolved or stream ended
if (state.doneResolved || state.streamEnded) {
@@ -308,9 +319,14 @@ function pollReadableLock(
// Check if lock is released (not closed) and no pending ops
if (isReadableUnlockedNotClosed(readable) && state.pendingOps === 0) {
- state.doneResolved = true;- state.resolve();- clearInterval(intervalId);+ stableCount++;+ if (stableCount >= STABLE_POLL_COUNT) {+ state.doneResolved = true;+ state.resolve();+ clearInterval(intervalId);+ }+ } else {+ stableCount = 0;
}
}, LOCK_POLL_INTERVAL_MS);
}

Analysis

Race condition in polling functions: done promise resolves before pump finishes flushing

What fails: The pollWritableLock() and pollReadableLock() functions in packages/core/src/serialization.ts resolve the done promise immediately when the stream lock is released and pendingOps === 0, without waiting for multiple consecutive stable polls. This creates a race condition where the done promise can resolve while the pump is still reading/writing data.

How to reproduce:

  1. A stream is being piped asynchronously via flushablePipe()
  2. User releases the lock on the stream (but doesn't close it)
  3. Pump is momentarily between reads (at await reader.read()) with pendingOps === 0
  4. Polling fires and sees: lock released + pendingOps === 0 → resolves done promise immediately
  5. Runtime awaits Promise.all(ops) which includes the done promise and proceeds
  6. If pump reads more data and tries to write, the step is already marked complete
  7. This violates the contract that all writes must be flushed before the step completes

Expected behavior: Per the docstring at lines 265-266 and 293-294, polling should only resolve after the condition is stable for "multiple consecutive polls (to avoid race conditions with the pump)." The test file at lines 1002-1003 and 1084-1090 confirms this behavior by using STABLE_POLL_COUNT = 2 - only resolving after the unlock condition is seen 2 consecutive times.

Root cause: The actual implementation was missing the stability counter logic that the docstring promised and that the test expected. Added STABLE_POLL_COUNT constant and stability tracking to both polling functions.

Evidence:

  • Docstring promises: "for multiple consecutive polls (to avoid race conditions with the pump)"
  • Test implementation (lines 1084-1090): Implements stability counter that resets when condition changes
  • Test comment (line 1001): "mirrors the actual implementation"
  • Git history: Commit e8c3800 added polling with docstring but without stability logic

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@TooTallNate@VaguelySerious
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Fix stream serialization to resolve when user releases lock instead of waiting for stream to close - #661

Closed
TooTallNate wants to merge 2 commits into
mainfrom
12-20-fix_stream_serialization_to_resolve_when_user_releases_lock_instead_of_waiting_for_stream_to_close
Closed

Fix stream serialization to resolve when user releases lock instead of waiting for stream to close#661
TooTallNate wants to merge 2 commits into
mainfrom
12-20-fix_stream_serialization_to_resolve_when_user_releases_lock_instead_of_waiting_for_stream_to_close

Conversation

@TooTallNate

@TooTallNateTooTallNate commented Dec 20, 2025

Copy link
Copy Markdown
Member

Fix stream serialization to resolve when users release locks instead of waiting for streams to close, preventing Vercel functions from hanging.

What changed?

  • Implemented a polling mechanism to detect when stream locks are released
  • Added flushablePipe function that resolves in two scenarios:
    1. When the stream completes normally (close/error)
    2. When the user releases their lock AND all pending writes are flushed
  • Created a state tracking system to monitor pending operations and lock status

How to test?

  1. Create a workflow step that incrementally writes to a stream:

    constwriter=stream.getWriter();awaitwriter.write(data1);writer.releaseLock();// Step should complete here without waiting
  2. Verify the step completes immediately after lock release rather than hanging

  3. Run the new test cases that verify both lock release and normal stream closure behaviors

Why make this change?

This fixes an issue where Vercel functions would hang when users incrementally write to streams within steps. Previously, the system would wait for the stream to fully close before resolving, but many users follow a pattern where they write data and release the lock without explicitly closing the stream. This change allows steps to complete as soon as the user releases the lock and all pending writes are flushed, which is the expected behavior in most streaming scenarios.

@changeset-bot

changeset-botBot commented Dec 20, 2025

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 579162a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 12 packages
NameType
@workflow/corePatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/web-sharedPatch
workflowPatch
@workflow/astroPatch
@workflow/sveltekitPatch
@workflow/world-testingPatch
@workflow/nuxtPatch
@workflow/aiPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actionsBot commented Dec 20, 2025

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Starter🥇 Next.js (Turbopack)0.039s (+0.5%)1.013s (~)0.974s101.00x
💻 LocalNext.js (Turbopack)0.040s (-2.0%)1.017s (~)0.977s101.03x
💻 LocalNitro0.042s (-0.7%)1.007s (~)0.965s101.08x
🌐 RedisNext.js (Turbopack)0.042s (+0.7%)1.017s (~)0.975s101.08x
💻 LocalExpress0.043s (~)1.008s (~)0.965s101.11x
🌐 TursoNext.js (Turbopack)0.071s (-28.1% 🟢)1.014s (~)0.943s101.84x
🌐 MongoDBNext.js (Turbopack)0.107s (+44.9% 🔺)1.014s (~)0.907s102.76x
🐘 PostgresNext.js (Turbopack)0.128s (-21.4% 🟢)1.018s (~)0.890s103.31x
🐘 PostgresNitro0.343s (+13.9% 🔺)1.012s (~)0.669s108.87x
🐘 PostgresExpress0.369s (+16.2% 🔺)1.014s (~)0.645s109.54x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express0.552s (-9.8% 🟢)1.429s (-2.2%)0.877s101.00x
▲ VercelNext.js (Turbopack)0.556s (-12.4% 🟢)1.526s (-3.7%)0.970s101.01x
▲ VercelNitro0.569s (+8.1% 🔺)1.522s (-23.2% 🟢)0.953s101.03x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Starter🥇 Next.js (Turbopack)1.095s (+0.6%)2.009s (~)0.914s101.00x
💻 LocalNext.js (Turbopack)1.095s (~)2.012s (~)0.916s101.00x
🌐 RedisNext.js (Turbopack)1.097s (~)2.012s (~)0.915s101.00x
💻 LocalNitro1.110s (~)2.006s (~)0.896s101.01x
💻 LocalExpress1.112s (~)2.007s (~)0.895s101.02x
🌐 TursoNext.js (Turbopack)1.293s (-0.8%)2.012s (~)0.719s101.18x
🌐 MongoDBNext.js (Turbopack)1.309s (+0.8%)2.012s (~)0.703s101.20x
🐘 PostgresNitro2.196s (+2.5%)3.013s (~)0.817s102.01x
🐘 PostgresExpress2.207s (+1.9%)3.014s (~)0.807s102.02x
🐘 PostgresNext.js (Turbopack)2.458s (+27.9% 🔺)3.017s (+49.6% 🔺)0.559s102.25x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.619s (-2.6%)3.592s (-2.9%)0.973s101.00x
▲ VercelExpress2.626s (-1.6%)3.502s (-1.2%)0.876s101.00x
▲ VercelNitro2.669s (-2.4%)3.624s (-2.0%)0.955s101.02x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Starter🥇 Next.js (Turbopack)10.588s (~)11.013s (~)0.425s51.00x
💻 LocalNext.js (Turbopack)10.632s (~)11.016s (~)0.384s51.00x
🌐 RedisNext.js (Turbopack)10.678s (~)11.021s (~)0.342s51.01x
💻 LocalExpress10.781s (~)11.014s (~)0.234s51.02x
💻 LocalNitro10.787s (~)11.013s (~)0.226s51.02x
🌐 TursoNext.js (Turbopack)12.192s (~)13.020s (~)0.828s51.15x
🌐 MongoDBNext.js (Turbopack)12.197s (~)13.019s (~)0.822s51.15x
🐘 PostgresExpress16.081s (-21.2% 🟢)16.837s (-20.0% 🟢)0.756s51.52x
🐘 PostgresNitro18.950s (-6.9% 🟢)19.634s (-5.8% 🟢)0.683s51.79x
🐘 PostgresNext.js (Turbopack)20.287s (+33.3% 🔺)21.046s (+31.2% 🔺)0.760s51.92x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro21.714s (+0.7%)22.834s (+1.8%)1.120s51.00x
▲ VercelExpress21.984s (+1.6%)23.013s (+3.2%)1.029s51.01x
▲ VercelNext.js (Turbopack)22.185s (+1.8%)23.116s (+2.5%)0.932s51.02x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Starter🥇 Next.js (Turbopack)1.334s (~)2.007s (~)0.673s151.00x
🌐 RedisNext.js (Turbopack)1.365s (+0.6%)2.011s (~)0.646s151.02x
💻 LocalNext.js (Turbopack)1.373s (-1.6%)2.010s (~)0.636s151.03x
💻 LocalNitro1.408s (~)2.005s (~)0.597s151.06x
💻 LocalExpress1.408s (~)2.006s (~)0.598s151.06x
🐘 PostgresNext.js (Turbopack)1.901s (+3.1%)2.164s (+4.7%)0.263s141.43x
🐘 PostgresExpress1.939s (-18.7% 🟢)2.225s (-26.1% 🟢)0.287s141.45x
🌐 MongoDBNext.js (Turbopack)2.130s (~)3.011s (~)0.881s101.60x
🌐 TursoNext.js (Turbopack)2.198s (~)3.014s (~)0.815s101.65x
🐘 PostgresNitro2.351s (~)2.921s (-3.0%)0.569s111.76x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.844s (+4.8%)3.777s (-0.9%)0.933s81.00x
▲ VercelNext.js (Turbopack)2.994s (-1.9%)3.767s (-5.3% 🟢)0.774s81.05x
▲ VercelExpress3.634s (+32.8% 🔺)4.564s (+29.8% 🔺)0.930s71.28x

🔍 Observability: Nitro | Next.js (Turbopack) | Express

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)2.081s (-3.8%)2.911s (-6.6% 🟢)0.830s111.00x
💻 LocalExpress2.206s (~)3.160s (~)0.954s101.06x
💻 LocalNitro2.224s (~)3.169s (~)0.945s101.07x
🌐 StarterNext.js (Turbopack)2.448s (~)3.009s (~)0.560s101.18x
🌐 RedisNext.js (Turbopack)2.506s (+0.5%)3.012s (~)0.507s101.20x
🐘 PostgresExpress2.704s (-7.4% 🟢)3.016s (-6.8% 🟢)0.312s101.30x
🐘 PostgresNitro2.713s (+2.2%)3.012s (-0.5%)0.298s101.30x
🐘 PostgresNext.js (Turbopack)2.920s (+10.9% 🔺)3.411s (+12.5% 🔺)0.491s91.40x
🌐 MongoDBNext.js (Turbopack)4.685s (-1.8%)5.180s (~)0.495s62.25x
🌐 TursoNext.js (Turbopack)4.704s (+0.7%)5.178s (~)0.474s62.26x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.558s (+16.9% 🔺)4.236s (+15.0% 🔺)0.678s81.00x
▲ VercelNext.js (Turbopack)3.664s (+15.3% 🔺)4.434s (+16.2% 🔺)0.770s71.03x
▲ VercelNitro3.779s (+21.7% 🔺)4.309s (+12.4% 🔺)0.530s91.06x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Starter🥇 Next.js (Turbopack)1.350s (-1.6%)2.007s (~)0.656s151.00x
🌐 RedisNext.js (Turbopack)1.371s (-1.1%)2.009s (~)0.638s151.02x
💻 LocalNext.js (Turbopack)1.388s (-0.7%)2.013s (~)0.625s151.03x
💻 LocalExpress1.408s (~)2.006s (~)0.598s151.04x
💻 LocalNitro1.431s (~)2.005s (~)0.574s151.06x
🐘 PostgresExpress1.706s (-3.4%)2.009s (~)0.303s151.26x
🐘 PostgresNitro1.775s (-5.3% 🟢)2.011s (~)0.236s151.31x
🐘 PostgresNext.js (Turbopack)2.026s (+24.5% 🔺)2.231s (+10.8% 🔺)0.206s141.50x
🌐 MongoDBNext.js (Turbopack)2.176s (+2.7%)3.011s (~)0.835s101.61x
🌐 TursoNext.js (Turbopack)2.233s (+0.7%)3.015s (~)0.782s101.65x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.872s (+7.4% 🔺)3.757s (~)0.885s81.00x
▲ VercelExpress2.879s (+6.2% 🔺)4.136s (+17.9% 🔺)1.257s81.00x
▲ VercelNext.js (Turbopack)2.974s (+11.8% 🔺)4.393s (+20.0% 🔺)1.419s71.04x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)2.056s (-10.4% 🟢)2.916s (-10.1% 🟢)0.860s111.00x
💻 LocalExpress2.221s (-1.0%)3.152s (-1.1%)0.931s101.08x
💻 LocalNitro2.249s (-5.9% 🟢)3.178s (-4.6%)0.929s101.09x
🌐 StarterNext.js (Turbopack)2.458s (~)3.010s (~)0.552s101.20x
🐘 PostgresExpress2.502s (-8.1% 🟢)3.017s (~)0.515s101.22x
🌐 RedisNext.js (Turbopack)2.508s (+0.8%)3.012s (~)0.504s101.22x
🐘 PostgresNext.js (Turbopack)2.734s (+3.0%)3.028s (~)0.294s101.33x
🐘 PostgresNitro2.791s (+19.2% 🔺)3.038s (+0.9%)0.246s101.36x
🌐 TursoNext.js (Turbopack)4.693s (-1.6%)5.181s (~)0.488s62.28x
🌐 MongoDBNext.js (Turbopack)4.748s (+0.9%)5.179s (~)0.431s62.31x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.181s (+7.2% 🔺)3.741s (+1.6%)0.559s91.00x
▲ VercelExpress3.352s (+4.0%)3.953s (+3.9%)0.601s81.05x
▲ VercelNext.js (Turbopack)3.540s (+10.7% 🔺)4.341s (+11.9% 🔺)0.800s71.11x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🌐 Starter🥇 Next.js (Turbopack)0.125s (-3.2%)1.005s (~)0.000s (+Infinity% 🔺)1.010s (~)0.885s101.00x
💻 LocalNext.js (Turbopack)0.135s (-8.2% 🟢)1.003s (~)0.019s (+14.1% 🔺)1.029s (~)0.895s101.08x
🌐 RedisNext.js (Turbopack)0.141s (-1.1%)1.005s (~)0.000s (+Infinity% 🔺)1.013s (~)0.872s101.13x
💻 LocalNitro0.176s (~)0.992s (~)0.015s (-6.9% 🟢)1.021s (~)0.845s101.41x
💻 LocalExpress0.176s (~)0.992s (~)0.016s (~)1.022s (~)0.846s101.41x
🌐 TursoNext.js (Turbopack)0.461s (-5.9% 🟢)0.985s (+2.5%)0.000s (~)1.012s (~)0.551s103.69x
🌐 MongoDBNext.js (Turbopack)0.523s (+1.9%)0.925s (-1.1%)0.000s (+Infinity% 🔺)1.013s (~)0.490s104.18x
🐘 PostgresExpress1.353s (-41.6% 🟢)1.687s (-38.1% 🟢)0.000s (+200.0% 🔺)2.012s (-33.3% 🟢)0.659s1010.81x
🐘 PostgresNitro1.370s (-39.7% 🟢)1.670s (-39.7% 🟢)0.000s (-100.0% 🟢)2.012s (-33.2% 🟢)0.642s1010.95x
🐘 PostgresNext.js (Turbopack)1.608s (+35.8% 🔺)2.296s (+34.4% 🔺)0.000s (+Infinity% 🔺)2.418s (+32.9% 🔺)0.811s1012.85x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.631s (-11.4% 🟢)3.110s (-3.9%)0.657s (-8.1% 🟢)4.223s (-6.0% 🟢)1.592s101.00x
▲ VercelNitro2.770s (+4.7%)3.322s (+3.1%)0.567s (-15.4% 🟢)4.412s (+2.5%)1.643s101.05x
▲ VercelNext.js (Turbopack)2.788s (+6.0% 🔺)3.253s (+1.8%)0.832s (+45.0% 🔺)4.510s (+7.5% 🔺)1.722s101.06x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNext.js (Turbopack)8/8
🐘 PostgresExpress5/8
▲ VercelNitro4/8
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express💻 Local8/8
Next.js (Turbopack)🌐 Starter6/8
Nitro💻 Local8/8
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Starter: Community world (local development)
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)

📋 View full workflow run

@github-actions

github-actionsBot commented Dec 20, 2025

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production286011297
✅ 💻 Local Development26208270
✅ 📦 Local Production26208270
✅ 🐘 Local Postgres26208270
✅ 🪟 Windows270027
❌ 🌍 Community Worlds109110120
Total120811351254

❌ Failed Tests

🌍 Community Worlds (11 failed)

mongodb (1 failed):

  • webhookWorkflow

redis (1 failed):

  • webhookWorkflow

starter (8 failed):

  • addTenWorkflow
  • addTenWorkflow
  • retryAttemptCounterWorkflow
  • crossFileErrorWorkflow - stack traces work across imported modules
  • hookCleanupTestWorkflow - hook token reuse after workflow completion
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step

turso (1 failed):

  • webhookWorkflow

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro2601
✅ example2601
✅ express2601
✅ fastify2601
✅ hono2601
✅ nextjs-turbopack2601
✅ nextjs-webpack2601
✅ nitro2601
✅ nuxt2601
✅ sveltekit2601
✅ vite2601
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable2601
✅ express-stable2601
✅ fastify-stable2601
✅ hono-stable2601
✅ nextjs-turbopack-stable2700
✅ nextjs-webpack-stable2700
✅ nitro-stable2601
✅ nuxt-stable2601
✅ sveltekit-stable2601
✅ vite-stable2601
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable2601
✅ express-stable2601
✅ fastify-stable2601
✅ hono-stable2601
✅ nextjs-turbopack-stable2700
✅ nextjs-webpack-stable2700
✅ nitro-stable2601
✅ nuxt-stable2601
✅ sveltekit-stable2601
✅ vite-stable2601
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable2601
✅ express-stable2601
✅ fastify-stable2601
✅ hono-stable2601
✅ nextjs-turbopack-stable2700
✅ nextjs-webpack-stable2700
✅ nitro-stable2601
✅ nuxt-stable2601
✅ sveltekit-stable2601
✅ vite-stable2601
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack2700
❌ 🌍 Community Worlds
AppPassedFailedSkipped
✅ mongodb-dev300
❌ mongodb2610
✅ redis-dev300
❌ redis2610
✅ starter-dev300
❌ starter1980
✅ turso-dev300
❌ turso2610

📋 View full workflow run

@vercel

vercelBot commented Dec 20, 2025

Copy link
Copy Markdown
Contributor

@TooTallNateGraphite App

TooTallNate commented Dec 20, 2025

Copy link
Copy Markdown
MemberAuthor

Comment on lines +356 to +362
} catch (err) {
state.streamEnded = true;
if (!state.doneResolved) {
state.doneResolved = true;
state.reject(err);
}
throw err;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stream locks are not released when an error occurs in the pipe. The error is thrown without releasing the locks acquired at the start of the function.

View Details
📝 Patch Details
diff --git a/packages/core/src/serialization.ts b/packages/core/src/serialization.ts
index c8ebc73..23e2ae6 100644
--- a/packages/core/src/serialization.ts+++ b/packages/core/src/serialization.ts@@ -359,6 +359,8 @@ async function flushablePipe(
state.doneResolved = true;
state.reject(err);
}
+ reader.releaseLock();+ writer.releaseLock();
throw err;
}
}

Analysis

Stream locks not released when error occurs in flushablePipe

What fails: The flushablePipe() function in packages/core/src/serialization.ts does not release the reader and writer locks when an error is thrown in the catch block (lines 356-362), leaving the streams in a permanently locked state.

How to reproduce: Create a test scenario where an error occurs during pipe operation:

// Create a readable streamconstsource=newReadableStream({start(controller){controller.enqueue('chunk1');controller.enqueue('chunk2');controller.close();}});// Create a writable stream that errors on second writeletwriteCount=0;constsink=newWritableStream({asyncwrite(chunk){writeCount++;if(writeCount===2){thrownewError('Write failed!');}}});// Call flushablePipe with error stateconststate={pendingOps: 0,doneResolved: false,streamEnded: false,resolve: ()=>{},reject: ()=>{}};try{awaitflushablePipe(source,sink,state);}catch(err){// Error caught}// Attempt to acquire new reader/writersource.getReader();// Throws: "InvalidStateError: ReadableStream is locked"sink.getWriter();// Throws: "InvalidStateError: WritableStream is locked"

Result: Streams remain locked (source.locked === true, sink.locked === true), preventing new readers/writers from being obtained. Cannot acquire new reader: "InvalidStateError: ReadableStream is locked". Cannot acquire new writer: "InvalidStateError: WritableStream is locked".

Expected behavior: The locks should be released before throwing the error, as they are in normal code paths (lines 326, 339, 346). Per WHATWG Streams API specification, a lock must be explicitly released, and a locked stream prevents other readers/writers from being obtained. Leaving locks unreleased creates a resource leak that prevents stream reuse.

Root cause: The catch block at lines 356-362 sets error state and throws without calling reader.releaseLock() and writer.releaseLock(), unlike all other exit paths from the function which properly release locks.

Fix applied: Added reader.releaseLock() and writer.releaseLock() before the throw err statement in the catch block to match the cleanup pattern used in normal code paths.

Comment on lines +329 to +338
if (readResult.done) {
// Source stream completed - close sink and resolve
state.streamEnded = true;
await writer.close();
// Resolve done promise if not already resolved
if (!state.doneResolved) {
state.doneResolved = true;
state.resolve();
}
return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stream locks are not released when the source stream ends. The flushablePipe function acquires locks on the reader and writer but doesn't release them before returning when the readable stream completes.

View Details
📝 Patch Details
diff --git a/packages/core/src/serialization.ts b/packages/core/src/serialization.ts
index c8ebc73..57e4750 100644
--- a/packages/core/src/serialization.ts+++ b/packages/core/src/serialization.ts@@ -335,6 +335,8 @@ async function flushablePipe(
state.doneResolved = true;
state.resolve();
}
+ reader.releaseLock();+ writer.releaseLock();
return;
}
@@ -359,6 +361,8 @@ async function flushablePipe(
state.doneResolved = true;
state.reject(err);
}
+ reader.releaseLock();+ writer.releaseLock();
throw err;
}
}

Analysis

Stream locks not released in flushablePipe() when source completes or errors

What fails: The flushablePipe() function in packages/core/src/serialization.ts (lines 333-341 and 365-371) acquires locks on ReadableStream and WritableStream but fails to release them before returning when the stream completes or when an error occurs.

How to reproduce:

// When readResult.done is true (source stream completed):constreadResult=awaitreader.read();if(readResult.done){state.streamEnded=true;awaitwriter.close();// ... resolve promise ...return;// ❌ Returns without releasing locks}// Or when error occurs:}catch(err){state.streamEnded=true;// ... reject promise ...throwerr;// ❌ Throws without releasing locks}

Result: Unreleased locks prevent any other code from acquiring new readers or writers on these streams. Subsequent attempts to call getReader() or getWriter() will throw TypeError: ReadableStreamDefaultReader constructor can only accept readable streams that are not yet locked to a reader. The stream becomes permanently inaccessible.

Expected behavior: According to MDN Web JavaScript resource management, unreleased locks cause the stream to remain locked indefinitely and "does not permit another reader to consume it." The locks must be explicitly released via reader.releaseLock() and writer.releaseLock() before the function exits.

Fix: Added reader.releaseLock() and writer.releaseLock() calls before all return/throw statements in the flushablePipe function:

  • Before returning when readResult.done (normal stream completion)
  • Before throwing in the catch block (error case)

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements a polling mechanism to detect when stream locks are released, allowing workflow steps to complete without waiting for streams to fully close. This fixes an issue where Vercel functions would hang when users write to streams and release locks without explicitly closing them.

Key Changes:

  • Added flushablePipe function that resolves when either the stream completes or when the user releases their lock with all pending writes flushed
  • Implemented polling functions (pollWritableLock and pollReadableLock) that check stream lock state every 100ms
  • Created state tracking system (FlushableStreamState) to monitor pending operations and resolution status

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 15 comments.

FileDescription
packages/core/src/serialization.tsImplements the core flushable pipe mechanism with polling-based lock detection and state tracking for both readable and writable streams
packages/core/src/serialization.test.tsAdds tests verifying the new behavior for both lock release and natural stream closure scenarios
.changeset/stream-lock-polling.mdDocuments the patch-level change for the @workflow/core package

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +1174 to +1176
flushablePipe(readable, transform.writable, state).catch(() => {
// Errors are handled via state.reject
});

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The flushablePipe function throws errors after calling state.reject() (line 405), but all call sites catch and ignore these thrown errors with empty catch blocks (e.g., line 976, 992, 1016). This pattern is confusing because the error is both rejected through the state AND thrown. Since the error is already propagated via state.reject() which rejects the 'done' promise, the re-throw serves no purpose and the empty catch blocks make this unclear. Consider either removing the throw from flushablePipe or documenting why the error needs to be both rejected and thrown.

Copilot uses AI. Check for mistakes.
Comment on lines +399 to +406
} catch (err) {
state.streamEnded = true;
if (!state.doneResolved) {
state.doneResolved = true;
state.reject(err);
}
throw err;
}

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error handling in the flushablePipe function could potentially leak resources. When an exception occurs in the catch block at line 399, the reader and writer locks are not released before the error is re-thrown. This could leave the streams in a locked state. Consider releasing the locks in the catch block before re-throwing.

Copilot uses AI. Check for mistakes.
Comment on lines +229 to +261
/**
* Checks if a WritableStream is unlocked (user released lock) vs closed.
* When a stream is closed, .locked is false but getWriter() throws.
* We only want to resolve via polling when the stream is unlocked, not closed.
* If closed, the pump will handle resolution via the stream ending naturally.
*/
function isWritableUnlockedNotClosed(writable: WritableStream): boolean {
if (writable.locked) return false;

try {
// Try to acquire writer - if successful, stream is unlocked (not closed)
const writer = writable.getWriter();
writer.releaseLock();
return true;
} catch {
// getWriter() throws if stream is closed/errored - let pump handle it
return false;
}
}

/**
* Checks if a ReadableStream is unlocked (user released lock) vs closed.
*/
function isReadableUnlockedNotClosed(readable: ReadableStream): boolean {
if (readable.locked) return false;

try {
// Try to acquire reader - if successful, stream is unlocked (not closed)
const reader = readable.getReader();
reader.releaseLock();
return true;
} catch {
// getReader() throws if stream is closed/errored - let pump handle it

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The polling functions acquire and release stream locks every 100ms to check if a stream is unlocked vs closed. This repeated lock acquisition could have performance implications and might interfere with stream operations. Consider using a different approach to detect stream closure, such as checking for specific error messages or using a flag that's set when the stream closes, to avoid repeatedly acquiring locks during polling.

Suggested change
/**
*ChecksifaWritableStreamisunlocked(userreleasedlock)vsclosed.
*Whenastreamisclosed,.lockedisfalsebutgetWriter()throws.
*Weonlywanttoresolveviapollingwhenthestreamisunlocked,notclosed.
*Ifclosed,thepumpwillhandleresolutionviathestreamendingnaturally.
*/
functionisWritableUnlockedNotClosed(writable: WritableStream): boolean{
if(writable.locked)returnfalse;
try{
// Try to acquire writer - if successful, stream is unlocked (not closed)
constwriter=writable.getWriter();
writer.releaseLock();
returntrue;
}catch{
// getWriter() throws if stream is closed/errored - let pump handle it
returnfalse;
}
}
/**
*ChecksifaReadableStreamisunlocked(userreleasedlock)vsclosed.
*/
functionisReadableUnlockedNotClosed(readable: ReadableStream): boolean{
if(readable.locked)returnfalse;
try{
// Try to acquire reader - if successful, stream is unlocked (not closed)
constreader=readable.getReader();
reader.releaseLock();
returntrue;
}catch{
// getReader() throws if stream is closed/errored - let pump handle it
/**
*Cacheofper-stream"unlocked but not closed"statusforWritableStreams.
*Thisavoidsrepeatedlyacquiringandreleasinglocksduringpolling;we
*probethestreamatmostonceperinstancewhenittransitionstounlocked.
*/
constwritableUnlockedNotClosedCache=newWeakMap<WritableStream,boolean>();
/**
*ChecksifaWritableStreamisunlocked(userreleasedlock)vsclosed.
*Whenastreamisclosed,.lockedisfalsebutgetWriter()throws.
*Weonlywanttoresolveviapollingwhenthestreamisunlocked,notclosed.
*Ifclosed,thepumpwillhandleresolutionviathestreamendingnaturally.
*
*Toavoidrepeatedlockacquisitionduringpolling,wecachetheresultof
*theunlocked-vs-closedprobeperstream.Oncedetermined,thestatus is
*reusedonsubsequentcallsforthesamestream.
*/
functionisWritableUnlockedNotClosed(writable: WritableStream): boolean{
if(writable.locked)returnfalse;
constcached=writableUnlockedNotClosedCache.get(writable);
if(cached!==undefined){
returncached;
}
try{
// Try to acquire writer - if successful, stream is unlocked (not closed)
constwriter=writable.getWriter();
writer.releaseLock();
writableUnlockedNotClosedCache.set(writable,true);
returntrue;
}catch{
// getWriter() throws if stream is closed/errored - let pump handle it
writableUnlockedNotClosedCache.set(writable,false);
returnfalse;
}
}
/**
*Cacheofper-stream"unlocked but not closed"statusforReadableStreams.
*Likethewritablecache,thispreventsrepeatedreaderacquisitionswhile
*pollingforlockrelease.
*/
constreadableUnlockedNotClosedCache=newWeakMap<ReadableStream,boolean>();
/**
*ChecksifaReadableStreamisunlocked(userreleasedlock)vsclosed.
*Usesaper-streamcachetoavoidrepeatedlyacquiringandreleasinglocks
*duringpolling.
*/
functionisReadableUnlockedNotClosed(readable: ReadableStream): boolean{
if(readable.locked)returnfalse;
constcached=readableUnlockedNotClosedCache.get(readable);
if(cached!==undefined){
returncached;
}
try{
// Try to acquire reader - if successful, stream is unlocked (not closed)
constreader=readable.getReader();
reader.releaseLock();
readableUnlockedNotClosedCache.set(readable,true);
returntrue;
}catch{
// getReader() throws if stream is closed/errored - let pump handle it
readableUnlockedNotClosedCache.set(readable,false);

Copilot uses AI. Check for mistakes.
Comment on lines +1179 to +1186
} catch (err) {
state.streamEnded = true;
if (!state.doneResolved) {
state.doneResolved = true;
state.reject(err as Error);
}
}
})();

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error handling in the test's pipe implementation (line 1179-1185) doesn't release the reader and writer locks before exiting, similar to the issue in the actual flushablePipe implementation. While this mirrors the production code, it would be better to release the locks in the catch block to properly clean up resources. Consider updating the test to match the corrected implementation once the production code is fixed.

Copilot uses AI. Check for mistakes.
Comment on lines +1016 to +1018
flushablePipe(serialize.readable, serverWritable, state).catch(() => {
// Errors are handled via state.reject
});

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The flushablePipe function throws errors after calling state.reject() (line 405), but all call sites catch and ignore these thrown errors with empty catch blocks (e.g., line 976, 992, 1016). This pattern is confusing because the error is both rejected through the state AND thrown. Since the error is already propagated via state.reject() which rejects the 'done' promise, the re-throw serves no purpose and the empty catch blocks make this unclear. Consider either removing the throw from flushablePipe or documenting why the error needs to be both rejected and thrown.

Suggested change
flushablePipe(serialize.readable,serverWritable,state).catch(()=>{
// Errors are handled via state.reject
});
flushablePipe(serialize.readable,serverWritable,state).catch(
(error)=>{
// flushablePipe already calls state.reject(error), which rejects the
// `done` promise added to `ops`. We catch and intentionally ignore
// this rejection here only to avoid an unhandled promise rejection;
// all real error handling flows through state.done.
}
);

Copilot uses AI. Check for mistakes.
Comment on lines +1001 to +1229
describe('flushable stream behavior', () => {
const POLL_INTERVAL = 100; // Match the actual implementation
const STABLE_POLL_COUNT = 2; // Match the actual implementation

it('done promise should resolve when writable stream lock is released (polling)', async () => {
// Test the pattern: user writes, releases lock, polling detects it, done resolves
const chunks: string[] = [];
let streamClosed = false;

// Create a simple mock for the sink
const mockSink = new WritableStream<string>({
write(chunk) {
chunks.push(chunk);
},
close() {
streamClosed = true;
},
});

// Create a TransformStream like we do in getStepRevivers
const { readable, writable } = new TransformStream<string, string>();

// Track flushable state - this mirrors the actual implementation
const state = {
pendingOps: 0, // Only counts writes to server
doneResolved: false,
streamEnded: false,
resolve: () => {},
reject: (_err: Error) => {},
};

const done = new Promise<void>((res, rej) => {
state.resolve = res;
state.reject = rej;
});

// Start piping in background (mirrors flushablePipe implementation)
(async () => {
const reader = readable.getReader();
const writer = mockSink.getWriter();
try {
while (!state.streamEnded) {
const result = await reader.read();

if (result.done) {
state.streamEnded = true;
await writer.close();
if (!state.doneResolved) {
state.doneResolved = true;
state.resolve();
}
return;
}

// Only writes count as pending ops
state.pendingOps++;
await writer.write(result.value);
state.pendingOps--;

if (state.streamEnded) {
reader.releaseLock();
writer.releaseLock();
return;
}
}
} catch (err) {
state.streamEnded = true;
if (!state.doneResolved) {
state.doneResolved = true;
state.reject(err as Error);
}
}
})();

// Start polling (mirrors pollWritableLock implementation)
let stableCount = 0;
const intervalId = setInterval(() => {
if (state.doneResolved || state.streamEnded) {
clearInterval(intervalId);
return;
}

// Check if lock is released by checking .locked property
if (!writable.locked && state.pendingOps === 0) {
stableCount++;
if (stableCount >= STABLE_POLL_COUNT) {
state.doneResolved = true;
state.resolve();
clearInterval(intervalId);
}
} else {
stableCount = 0;
}
}, POLL_INTERVAL);

// Simulate user interaction - write and release lock
const userWriter = writable.getWriter();
await userWriter.write('chunk1');
await userWriter.write('chunk2');

// Release lock without closing stream
userWriter.releaseLock();

// Wait for pipe to process + polling intervals (need STABLE_POLL_COUNT consecutive polls)
await new Promise((r) => setTimeout(r, 250));

// The done promise should resolve
await expect(
Promise.race([
done,
new Promise((_, r) => setTimeout(() => r(new Error('timeout')), 400)),
])
).resolves.toBeUndefined();

// Chunks should have been written
expect(chunks).toContain('chunk1');
expect(chunks).toContain('chunk2');

// Stream should NOT be closed (user only released lock)
expect(streamClosed).toBe(false);
});

it('done promise should resolve when writable stream closes naturally', async () => {
const chunks: string[] = [];
let streamClosed = false;

const mockSink = new WritableStream<string>({
write(chunk) {
chunks.push(chunk);
},
close() {
streamClosed = true;
},
});

const { readable, writable } = new TransformStream<string, string>();

const state = {
pendingOps: 0,
doneResolved: false,
streamEnded: false,
resolve: () => {},
reject: (_err: Error) => {},
};

const done = new Promise<void>((res, rej) => {
state.resolve = res;
state.reject = rej;
});

// Start piping in background
(async () => {
const reader = readable.getReader();
const writer = mockSink.getWriter();
try {
while (!state.streamEnded) {
const result = await reader.read();

if (result.done) {
state.streamEnded = true;
await writer.close();
if (!state.doneResolved) {
state.doneResolved = true;
state.resolve();
}
return;
}

state.pendingOps++;
await writer.write(result.value);
state.pendingOps--;

if (state.streamEnded) {
reader.releaseLock();
writer.releaseLock();
return;
}
}
} catch (err) {
state.streamEnded = true;
if (!state.doneResolved) {
state.doneResolved = true;
state.reject(err as Error);
}
}
})();

// Start polling (won't trigger since stream will close first)
let stableCount2 = 0;
const intervalId = setInterval(() => {
if (state.doneResolved || state.streamEnded) {
clearInterval(intervalId);
return;
}
if (!writable.locked && state.pendingOps === 0) {
stableCount2++;
if (stableCount2 >= STABLE_POLL_COUNT) {
state.doneResolved = true;
state.resolve();
clearInterval(intervalId);
}
} else {
stableCount2 = 0;
}
}, POLL_INTERVAL);

// User writes and then closes the stream
const userWriter = writable.getWriter();
await userWriter.write('data');
await userWriter.close();

// Wait a tick for the pipe to process
await new Promise((r) => setTimeout(r, 50));

// The done promise should resolve
await expect(
Promise.race([
done,
new Promise((_, r) => setTimeout(() => r(new Error('timeout')), 200)),
])
).resolves.toBeUndefined();

// Chunks should have been written
expect(chunks).toContain('data');

// Stream should be closed (user closed it)
expect(streamClosed).toBe(true);
});
});

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new flushable stream behavior tests only cover the happy paths (lock release and normal closure). Consider adding test cases for error scenarios such as: stream errors during write operations, reader errors, and what happens when the stream is aborted. This would help ensure the error handling in flushablePipe and the polling mechanisms work correctly under failure conditions.

Copilot uses AI. Check for mistakes.
Comment on lines +1158 to +1160
flushablePipe(readable, writable, state).catch(() => {
// Errors are handled via state.reject
});

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The flushablePipe function throws errors after calling state.reject() (line 405), but all call sites catch and ignore these thrown errors with empty catch blocks (e.g., line 976, 992, 1016). This pattern is confusing because the error is both rejected through the state AND thrown. Since the error is already propagated via state.reject() which rejects the 'done' promise, the re-throw serves no purpose and the empty catch blocks make this unclear. Consider either removing the throw from flushablePipe or documenting why the error needs to be both rejected and thrown.

Copilot uses AI. Check for mistakes.
Comment on lines +192 to +196
* - `doneResolved`: The `done` promise has been resolved (step can complete)
* - `streamEnded`: The underlying stream has actually closed/errored
*
* The pump continues running even after `doneResolved=true` to handle
* any future writes if the user acquires a new lock.

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment states that "The pump continues running even after doneResolved=true to handle any future writes if the user acquires a new lock." However, this design could be problematic. Once the done promise resolves (pushed to ops), the workflow step will complete. If the user then acquires a new lock and writes more data after the step has completed, it could lead to unexpected behavior where data is being written to a stream associated with an already-completed step. Consider whether this is the intended behavior or if the stream should be closed/locked after the done promise resolves.

Suggested change
*-`doneResolved`: The`done`promisehasbeenresolved(stepcancomplete)
*-`streamEnded`: Theunderlyingstreamhasactuallyclosed/errored
*
*Thepumpcontinuesrunningevenafter`doneResolved=true`tohandle
* any futurewritesiftheuseracquiresanewlock.
*-`doneResolved`: The`done`promisehasbeenresolved(stepcancomplete),
*andnofurtherwritesshouldbeacceptedforthisstream.
*-`streamEnded`: Theunderlyingstreamhasactuallyclosed/errored.
*
*Once`doneResolved`becomes`true`,thepumpshouldonlydrainany
*already-pendingoperationsandthenstop;itmustnothandlenewwrites
*fromsubsequentlyacquiredlocks.

Copilot uses AI. Check for mistakes.
Comment on lines +1066 to +1073
} catch (err) {
state.streamEnded = true;
if (!state.doneResolved) {
state.doneResolved = true;
state.reject(err as Error);
}
}
})();

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error handling in the test's pipe implementation (line 1066-1072) doesn't release the reader and writer locks before exiting, similar to the issue in the actual flushablePipe implementation. While this mirrors the production code, it would be better to release the locks in the catch block to properly clean up resources. Consider updating the test to match the corrected implementation once the production code is fixed.

Copilot uses AI. Check for mistakes.
Comment on lines +1189 to +1203
let stableCount2 = 0;
const intervalId = setInterval(() => {
if (state.doneResolved || state.streamEnded) {
clearInterval(intervalId);
return;
}
if (!writable.locked && state.pendingOps === 0) {
stableCount2++;
if (stableCount2 >= STABLE_POLL_COUNT) {
state.doneResolved = true;
state.resolve();
clearInterval(intervalId);
}
} else {
stableCount2 = 0;

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The variable name stableCount2 is unclear and suggests copy-paste from the first test. Consider renaming to stableCount for consistency, as the numbering doesn't add clarity and makes the code harder to read.

Suggested change
letstableCount2=0;
constintervalId=setInterval(()=>{
if(state.doneResolved||state.streamEnded){
clearInterval(intervalId);
return;
}
if(!writable.locked&&state.pendingOps===0){
stableCount2++;
if(stableCount2>=STABLE_POLL_COUNT){
state.doneResolved=true;
state.resolve();
clearInterval(intervalId);
}
}else{
stableCount2=0;
letstableCount=0;
constintervalId=setInterval(()=>{
if(state.doneResolved||state.streamEnded){
clearInterval(intervalId);
return;
}
if(!writable.locked&&state.pendingOps===0){
stableCount++;
if(stableCount>=STABLE_POLL_COUNT){
state.doneResolved=true;
state.resolve();
clearInterval(intervalId);
}
}else{
stableCount=0;

Copilot uses AI. Check for mistakes.

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before I review - there's a ton of AI comments on this PR and I want to give you a chance to resolve those, so I'm subscribing and re-reviewing after the next commit/message

Base automatically changed from 12-22-fix_race_conditions_in_webhook_response_handling to mainDecember 22, 2025 18:41
@TooTallNate

Copy link
Copy Markdown
MemberAuthor

@copilot Please evaluate the code review suggestions in this PR and apply them if you think they are relevent.

CopilotAI commented Dec 22, 2025

Copy link
Copy Markdown
Contributor

@TooTallNate I've opened a new pull request, #677, to work on those changes. Once the pull request is ready, I'll request review from you.

…f waiting for stream to close
This prevents Vercel functions from hanging when users incrementally write to streams within steps (e.g., `await writer.write(data); writer.releaseLock()`). Uses a polling approach to detect when the stream lock is released and all pending writes are flushed.
Comment on lines +271 to +316
function pollWritableLock(
writable: WritableStream,
state: FlushableStreamState
): void {
const intervalId = setInterval(() => {
// Stop polling if already resolved or stream ended
if (state.doneResolved || state.streamEnded) {
clearInterval(intervalId);
return;
}

// Check if lock is released (not closed) and no pending ops
if (isWritableUnlockedNotClosed(writable) && state.pendingOps === 0) {
state.doneResolved = true;
state.resolve();
clearInterval(intervalId);
}
}, LOCK_POLL_INTERVAL_MS);
}

/**
* Polls a ReadableStream to check if the user has released their lock.
* Resolves the done promise when lock is released and no pending ops remain.
*
* Note: Only resolves if stream is unlocked but NOT closed. If the user closes
* the stream, the pump will handle resolution via the stream ending naturally.
*/
function pollReadableLock(
readable: ReadableStream,
state: FlushableStreamState
): void {
const intervalId = setInterval(() => {
// Stop polling if already resolved or stream ended
if (state.doneResolved || state.streamEnded) {
clearInterval(intervalId);
return;
}

// Check if lock is released (not closed) and no pending ops
if (isReadableUnlockedNotClosed(readable) && state.pendingOps === 0) {
state.doneResolved = true;
state.resolve();
clearInterval(intervalId);
}
}, LOCK_POLL_INTERVAL_MS);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The polling functions don't implement the "multiple consecutive polls" behavior mentioned in the docstring, creating a potential race condition where the done promise resolves before the pump finishes writing queued data.

View Details
📝 Patch Details
diff --git a/packages/core/src/serialization.ts b/packages/core/src/serialization.ts
index 69c391b..376afe4 100644
--- a/packages/core/src/serialization.ts+++ b/packages/core/src/serialization.ts@@ -182,6 +182,9 @@ export class WorkflowServerWritableStream extends WritableStream<Uint8Array> {
/** Polling interval for checking stream lock state (in milliseconds) */
const LOCK_POLL_INTERVAL_MS = 100;
+/** Number of consecutive stable polls before resolving done promise */+const STABLE_POLL_COUNT = 2;+
/**
* State tracker for flushable stream operations.
* Resolves when either:
@@ -272,6 +275,7 @@ function pollWritableLock(
writable: WritableStream,
state: FlushableStreamState
): void {
+ let stableCount = 0;
const intervalId = setInterval(() => {
// Stop polling if already resolved or stream ended
if (state.doneResolved || state.streamEnded) {
@@ -281,16 +285,22 @@ function pollWritableLock(
// Check if lock is released (not closed) and no pending ops
if (isWritableUnlockedNotClosed(writable) && state.pendingOps === 0) {
- state.doneResolved = true;- state.resolve();- clearInterval(intervalId);+ stableCount++;+ if (stableCount >= STABLE_POLL_COUNT) {+ state.doneResolved = true;+ state.resolve();+ clearInterval(intervalId);+ }+ } else {+ stableCount = 0;
}
}, LOCK_POLL_INTERVAL_MS);
}
/**
* Polls a ReadableStream to check if the user has released their lock.
- * Resolves the done promise when lock is released and no pending ops remain.+ * Resolves the done promise when lock is released and no pending ops remain+ * for multiple consecutive polls (to avoid race conditions with the pump).
*
* Note: Only resolves if stream is unlocked but NOT closed. If the user closes
* the stream, the pump will handle resolution via the stream ending naturally.
@@ -299,6 +309,7 @@ function pollReadableLock(
readable: ReadableStream,
state: FlushableStreamState
): void {
+ let stableCount = 0;
const intervalId = setInterval(() => {
// Stop polling if already resolved or stream ended
if (state.doneResolved || state.streamEnded) {
@@ -308,9 +319,14 @@ function pollReadableLock(
// Check if lock is released (not closed) and no pending ops
if (isReadableUnlockedNotClosed(readable) && state.pendingOps === 0) {
- state.doneResolved = true;- state.resolve();- clearInterval(intervalId);+ stableCount++;+ if (stableCount >= STABLE_POLL_COUNT) {+ state.doneResolved = true;+ state.resolve();+ clearInterval(intervalId);+ }+ } else {+ stableCount = 0;
}
}, LOCK_POLL_INTERVAL_MS);
}

Analysis

Race condition in polling functions: done promise resolves before pump finishes flushing

What fails: The pollWritableLock() and pollReadableLock() functions in packages/core/src/serialization.ts resolve the done promise immediately when the stream lock is released and pendingOps === 0, without waiting for multiple consecutive stable polls. This creates a race condition where the done promise can resolve while the pump is still reading/writing data.

How to reproduce:

  1. A stream is being piped asynchronously via flushablePipe()
  2. User releases the lock on the stream (but doesn't close it)
  3. Pump is momentarily between reads (at await reader.read()) with pendingOps === 0
  4. Polling fires and sees: lock released + pendingOps === 0 → resolves done promise immediately
  5. Runtime awaits Promise.all(ops) which includes the done promise and proceeds
  6. If pump reads more data and tries to write, the step is already marked complete
  7. This violates the contract that all writes must be flushed before the step completes

Expected behavior: Per the docstring at lines 265-266 and 293-294, polling should only resolve after the condition is stable for "multiple consecutive polls (to avoid race conditions with the pump)." The test file at lines 1002-1003 and 1084-1090 confirms this behavior by using STABLE_POLL_COUNT = 2 - only resolving after the unlock condition is seen 2 consecutive times.

Root cause: The actual implementation was missing the stability counter logic that the docstring promised and that the test expected. Added STABLE_POLL_COUNT constant and stability tracking to both polling functions.

Evidence:

  • Docstring promises: "for multiple consecutive polls (to avoid race conditions with the pump)"
  • Test implementation (lines 1084-1090): Implements stability counter that resets when condition changes
  • Test comment (line 1001): "mirrors the actual implementation"
  • Git history: Commit e8c3800 added polling with docstring but without stability logic

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@TooTallNate@VaguelySerious
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Fix stream serialization to resolve when user releases lock instead of waiting for stream to close - #661

Closed
TooTallNate wants to merge 2 commits into
mainfrom
12-20-fix_stream_serialization_to_resolve_when_user_releases_lock_instead_of_waiting_for_stream_to_close
Closed

Fix stream serialization to resolve when user releases lock instead of waiting for stream to close#661
TooTallNate wants to merge 2 commits into
mainfrom
12-20-fix_stream_serialization_to_resolve_when_user_releases_lock_instead_of_waiting_for_stream_to_close

Conversation

@TooTallNate

@TooTallNateTooTallNate commented Dec 20, 2025

Copy link
Copy Markdown
Member

Fix stream serialization to resolve when users release locks instead of waiting for streams to close, preventing Vercel functions from hanging.

What changed?

  • Implemented a polling mechanism to detect when stream locks are released
  • Added flushablePipe function that resolves in two scenarios:
    1. When the stream completes normally (close/error)
    2. When the user releases their lock AND all pending writes are flushed
  • Created a state tracking system to monitor pending operations and lock status

How to test?

  1. Create a workflow step that incrementally writes to a stream:

    constwriter=stream.getWriter();awaitwriter.write(data1);writer.releaseLock();// Step should complete here without waiting
  2. Verify the step completes immediately after lock release rather than hanging

  3. Run the new test cases that verify both lock release and normal stream closure behaviors

Why make this change?

This fixes an issue where Vercel functions would hang when users incrementally write to streams within steps. Previously, the system would wait for the stream to fully close before resolving, but many users follow a pattern where they write data and release the lock without explicitly closing the stream. This change allows steps to complete as soon as the user releases the lock and all pending writes are flushed, which is the expected behavior in most streaming scenarios.

@changeset-bot

changeset-botBot commented Dec 20, 2025

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 579162a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 12 packages
NameType
@workflow/corePatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/web-sharedPatch
workflowPatch
@workflow/astroPatch
@workflow/sveltekitPatch
@workflow/world-testingPatch
@workflow/nuxtPatch
@workflow/aiPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actionsBot commented Dec 20, 2025

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Starter🥇 Next.js (Turbopack)0.039s (+0.5%)1.013s (~)0.974s101.00x
💻 LocalNext.js (Turbopack)0.040s (-2.0%)1.017s (~)0.977s101.03x
💻 LocalNitro0.042s (-0.7%)1.007s (~)0.965s101.08x
🌐 RedisNext.js (Turbopack)0.042s (+0.7%)1.017s (~)0.975s101.08x
💻 LocalExpress0.043s (~)1.008s (~)0.965s101.11x
🌐 TursoNext.js (Turbopack)0.071s (-28.1% 🟢)1.014s (~)0.943s101.84x
🌐 MongoDBNext.js (Turbopack)0.107s (+44.9% 🔺)1.014s (~)0.907s102.76x
🐘 PostgresNext.js (Turbopack)0.128s (-21.4% 🟢)1.018s (~)0.890s103.31x
🐘 PostgresNitro0.343s (+13.9% 🔺)1.012s (~)0.669s108.87x
🐘 PostgresExpress0.369s (+16.2% 🔺)1.014s (~)0.645s109.54x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express0.552s (-9.8% 🟢)1.429s (-2.2%)0.877s101.00x
▲ VercelNext.js (Turbopack)0.556s (-12.4% 🟢)1.526s (-3.7%)0.970s101.01x
▲ VercelNitro0.569s (+8.1% 🔺)1.522s (-23.2% 🟢)0.953s101.03x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Starter🥇 Next.js (Turbopack)1.095s (+0.6%)2.009s (~)0.914s101.00x
💻 LocalNext.js (Turbopack)1.095s (~)2.012s (~)0.916s101.00x
🌐 RedisNext.js (Turbopack)1.097s (~)2.012s (~)0.915s101.00x
💻 LocalNitro1.110s (~)2.006s (~)0.896s101.01x
💻 LocalExpress1.112s (~)2.007s (~)0.895s101.02x
🌐 TursoNext.js (Turbopack)1.293s (-0.8%)2.012s (~)0.719s101.18x
🌐 MongoDBNext.js (Turbopack)1.309s (+0.8%)2.012s (~)0.703s101.20x
🐘 PostgresNitro2.196s (+2.5%)3.013s (~)0.817s102.01x
🐘 PostgresExpress2.207s (+1.9%)3.014s (~)0.807s102.02x
🐘 PostgresNext.js (Turbopack)2.458s (+27.9% 🔺)3.017s (+49.6% 🔺)0.559s102.25x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.619s (-2.6%)3.592s (-2.9%)0.973s101.00x
▲ VercelExpress2.626s (-1.6%)3.502s (-1.2%)0.876s101.00x
▲ VercelNitro2.669s (-2.4%)3.624s (-2.0%)0.955s101.02x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Starter🥇 Next.js (Turbopack)10.588s (~)11.013s (~)0.425s51.00x
💻 LocalNext.js (Turbopack)10.632s (~)11.016s (~)0.384s51.00x
🌐 RedisNext.js (Turbopack)10.678s (~)11.021s (~)0.342s51.01x
💻 LocalExpress10.781s (~)11.014s (~)0.234s51.02x
💻 LocalNitro10.787s (~)11.013s (~)0.226s51.02x
🌐 TursoNext.js (Turbopack)12.192s (~)13.020s (~)0.828s51.15x
🌐 MongoDBNext.js (Turbopack)12.197s (~)13.019s (~)0.822s51.15x
🐘 PostgresExpress16.081s (-21.2% 🟢)16.837s (-20.0% 🟢)0.756s51.52x
🐘 PostgresNitro18.950s (-6.9% 🟢)19.634s (-5.8% 🟢)0.683s51.79x
🐘 PostgresNext.js (Turbopack)20.287s (+33.3% 🔺)21.046s (+31.2% 🔺)0.760s51.92x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro21.714s (+0.7%)22.834s (+1.8%)1.120s51.00x
▲ VercelExpress21.984s (+1.6%)23.013s (+3.2%)1.029s51.01x
▲ VercelNext.js (Turbopack)22.185s (+1.8%)23.116s (+2.5%)0.932s51.02x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Starter🥇 Next.js (Turbopack)1.334s (~)2.007s (~)0.673s151.00x
🌐 RedisNext.js (Turbopack)1.365s (+0.6%)2.011s (~)0.646s151.02x
💻 LocalNext.js (Turbopack)1.373s (-1.6%)2.010s (~)0.636s151.03x
💻 LocalNitro1.408s (~)2.005s (~)0.597s151.06x
💻 LocalExpress1.408s (~)2.006s (~)0.598s151.06x
🐘 PostgresNext.js (Turbopack)1.901s (+3.1%)2.164s (+4.7%)0.263s141.43x
🐘 PostgresExpress1.939s (-18.7% 🟢)2.225s (-26.1% 🟢)0.287s141.45x
🌐 MongoDBNext.js (Turbopack)2.130s (~)3.011s (~)0.881s101.60x
🌐 TursoNext.js (Turbopack)2.198s (~)3.014s (~)0.815s101.65x
🐘 PostgresNitro2.351s (~)2.921s (-3.0%)0.569s111.76x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.844s (+4.8%)3.777s (-0.9%)0.933s81.00x
▲ VercelNext.js (Turbopack)2.994s (-1.9%)3.767s (-5.3% 🟢)0.774s81.05x
▲ VercelExpress3.634s (+32.8% 🔺)4.564s (+29.8% 🔺)0.930s71.28x

🔍 Observability: Nitro | Next.js (Turbopack) | Express

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)2.081s (-3.8%)2.911s (-6.6% 🟢)0.830s111.00x
💻 LocalExpress2.206s (~)3.160s (~)0.954s101.06x
💻 LocalNitro2.224s (~)3.169s (~)0.945s101.07x
🌐 StarterNext.js (Turbopack)2.448s (~)3.009s (~)0.560s101.18x
🌐 RedisNext.js (Turbopack)2.506s (+0.5%)3.012s (~)0.507s101.20x
🐘 PostgresExpress2.704s (-7.4% 🟢)3.016s (-6.8% 🟢)0.312s101.30x
🐘 PostgresNitro2.713s (+2.2%)3.012s (-0.5%)0.298s101.30x
🐘 PostgresNext.js (Turbopack)2.920s (+10.9% 🔺)3.411s (+12.5% 🔺)0.491s91.40x
🌐 MongoDBNext.js (Turbopack)4.685s (-1.8%)5.180s (~)0.495s62.25x
🌐 TursoNext.js (Turbopack)4.704s (+0.7%)5.178s (~)0.474s62.26x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.558s (+16.9% 🔺)4.236s (+15.0% 🔺)0.678s81.00x
▲ VercelNext.js (Turbopack)3.664s (+15.3% 🔺)4.434s (+16.2% 🔺)0.770s71.03x
▲ VercelNitro3.779s (+21.7% 🔺)4.309s (+12.4% 🔺)0.530s91.06x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Starter🥇 Next.js (Turbopack)1.350s (-1.6%)2.007s (~)0.656s151.00x
🌐 RedisNext.js (Turbopack)1.371s (-1.1%)2.009s (~)0.638s151.02x
💻 LocalNext.js (Turbopack)1.388s (-0.7%)2.013s (~)0.625s151.03x
💻 LocalExpress1.408s (~)2.006s (~)0.598s151.04x
💻 LocalNitro1.431s (~)2.005s (~)0.574s151.06x
🐘 PostgresExpress1.706s (-3.4%)2.009s (~)0.303s151.26x
🐘 PostgresNitro1.775s (-5.3% 🟢)2.011s (~)0.236s151.31x
🐘 PostgresNext.js (Turbopack)2.026s (+24.5% 🔺)2.231s (+10.8% 🔺)0.206s141.50x
🌐 MongoDBNext.js (Turbopack)2.176s (+2.7%)3.011s (~)0.835s101.61x
🌐 TursoNext.js (Turbopack)2.233s (+0.7%)3.015s (~)0.782s101.65x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.872s (+7.4% 🔺)3.757s (~)0.885s81.00x
▲ VercelExpress2.879s (+6.2% 🔺)4.136s (+17.9% 🔺)1.257s81.00x
▲ VercelNext.js (Turbopack)2.974s (+11.8% 🔺)4.393s (+20.0% 🔺)1.419s71.04x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)2.056s (-10.4% 🟢)2.916s (-10.1% 🟢)0.860s111.00x
💻 LocalExpress2.221s (-1.0%)3.152s (-1.1%)0.931s101.08x
💻 LocalNitro2.249s (-5.9% 🟢)3.178s (-4.6%)0.929s101.09x
🌐 StarterNext.js (Turbopack)2.458s (~)3.010s (~)0.552s101.20x
🐘 PostgresExpress2.502s (-8.1% 🟢)3.017s (~)0.515s101.22x
🌐 RedisNext.js (Turbopack)2.508s (+0.8%)3.012s (~)0.504s101.22x
🐘 PostgresNext.js (Turbopack)2.734s (+3.0%)3.028s (~)0.294s101.33x
🐘 PostgresNitro2.791s (+19.2% 🔺)3.038s (+0.9%)0.246s101.36x
🌐 TursoNext.js (Turbopack)4.693s (-1.6%)5.181s (~)0.488s62.28x
🌐 MongoDBNext.js (Turbopack)4.748s (+0.9%)5.179s (~)0.431s62.31x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.181s (+7.2% 🔺)3.741s (+1.6%)0.559s91.00x
▲ VercelExpress3.352s (+4.0%)3.953s (+3.9%)0.601s81.05x
▲ VercelNext.js (Turbopack)3.540s (+10.7% 🔺)4.341s (+11.9% 🔺)0.800s71.11x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🌐 Starter🥇 Next.js (Turbopack)0.125s (-3.2%)1.005s (~)0.000s (+Infinity% 🔺)1.010s (~)0.885s101.00x
💻 LocalNext.js (Turbopack)0.135s (-8.2% 🟢)1.003s (~)0.019s (+14.1% 🔺)1.029s (~)0.895s101.08x
🌐 RedisNext.js (Turbopack)0.141s (-1.1%)1.005s (~)0.000s (+Infinity% 🔺)1.013s (~)0.872s101.13x
💻 LocalNitro0.176s (~)0.992s (~)0.015s (-6.9% 🟢)1.021s (~)0.845s101.41x
💻 LocalExpress0.176s (~)0.992s (~)0.016s (~)1.022s (~)0.846s101.41x
🌐 TursoNext.js (Turbopack)0.461s (-5.9% 🟢)0.985s (+2.5%)0.000s (~)1.012s (~)0.551s103.69x
🌐 MongoDBNext.js (Turbopack)0.523s (+1.9%)0.925s (-1.1%)0.000s (+Infinity% 🔺)1.013s (~)0.490s104.18x
🐘 PostgresExpress1.353s (-41.6% 🟢)1.687s (-38.1% 🟢)0.000s (+200.0% 🔺)2.012s (-33.3% 🟢)0.659s1010.81x
🐘 PostgresNitro1.370s (-39.7% 🟢)1.670s (-39.7% 🟢)0.000s (-100.0% 🟢)2.012s (-33.2% 🟢)0.642s1010.95x
🐘 PostgresNext.js (Turbopack)1.608s (+35.8% 🔺)2.296s (+34.4% 🔺)0.000s (+Infinity% 🔺)2.418s (+32.9% 🔺)0.811s1012.85x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.631s (-11.4% 🟢)3.110s (-3.9%)0.657s (-8.1% 🟢)4.223s (-6.0% 🟢)1.592s101.00x
▲ VercelNitro2.770s (+4.7%)3.322s (+3.1%)0.567s (-15.4% 🟢)4.412s (+2.5%)1.643s101.05x
▲ VercelNext.js (Turbopack)2.788s (+6.0% 🔺)3.253s (+1.8%)0.832s (+45.0% 🔺)4.510s (+7.5% 🔺)1.722s101.06x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNext.js (Turbopack)8/8
🐘 PostgresExpress5/8
▲ VercelNitro4/8
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express💻 Local8/8
Next.js (Turbopack)🌐 Starter6/8
Nitro💻 Local8/8
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Starter: Community world (local development)
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)

📋 View full workflow run

@github-actions

github-actionsBot commented Dec 20, 2025

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production286011297
✅ 💻 Local Development26208270
✅ 📦 Local Production26208270
✅ 🐘 Local Postgres26208270
✅ 🪟 Windows270027
❌ 🌍 Community Worlds109110120
Total120811351254

❌ Failed Tests

🌍 Community Worlds (11 failed)

mongodb (1 failed):

  • webhookWorkflow

redis (1 failed):

  • webhookWorkflow

starter (8 failed):

  • addTenWorkflow
  • addTenWorkflow
  • retryAttemptCounterWorkflow
  • crossFileErrorWorkflow - stack traces work across imported modules
  • hookCleanupTestWorkflow - hook token reuse after workflow completion
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step

turso (1 failed):

  • webhookWorkflow

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro2601
✅ example2601
✅ express2601
✅ fastify2601
✅ hono2601
✅ nextjs-turbopack2601
✅ nextjs-webpack2601
✅ nitro2601
✅ nuxt2601
✅ sveltekit2601
✅ vite2601
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable2601
✅ express-stable2601
✅ fastify-stable2601
✅ hono-stable2601
✅ nextjs-turbopack-stable2700
✅ nextjs-webpack-stable2700
✅ nitro-stable2601
✅ nuxt-stable2601
✅ sveltekit-stable2601
✅ vite-stable2601
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable2601
✅ express-stable2601
✅ fastify-stable2601
✅ hono-stable2601
✅ nextjs-turbopack-stable2700
✅ nextjs-webpack-stable2700
✅ nitro-stable2601
✅ nuxt-stable2601
✅ sveltekit-stable2601
✅ vite-stable2601
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable2601
✅ express-stable2601
✅ fastify-stable2601
✅ hono-stable2601
✅ nextjs-turbopack-stable2700
✅ nextjs-webpack-stable2700
✅ nitro-stable2601
✅ nuxt-stable2601
✅ sveltekit-stable2601
✅ vite-stable2601
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack2700
❌ 🌍 Community Worlds
AppPassedFailedSkipped
✅ mongodb-dev300
❌ mongodb2610
✅ redis-dev300
❌ redis2610
✅ starter-dev300
❌ starter1980
✅ turso-dev300
❌ turso2610

📋 View full workflow run

@vercel

vercelBot commented Dec 20, 2025

Copy link
Copy Markdown
Contributor

@TooTallNateGraphite App

TooTallNate commented Dec 20, 2025

Copy link
Copy Markdown
MemberAuthor

Comment on lines +356 to +362
} catch (err) {
state.streamEnded = true;
if (!state.doneResolved) {
state.doneResolved = true;
state.reject(err);
}
throw err;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stream locks are not released when an error occurs in the pipe. The error is thrown without releasing the locks acquired at the start of the function.

View Details
📝 Patch Details
diff --git a/packages/core/src/serialization.ts b/packages/core/src/serialization.ts
index c8ebc73..23e2ae6 100644
--- a/packages/core/src/serialization.ts+++ b/packages/core/src/serialization.ts@@ -359,6 +359,8 @@ async function flushablePipe(
state.doneResolved = true;
state.reject(err);
}
+ reader.releaseLock();+ writer.releaseLock();
throw err;
}
}

Analysis

Stream locks not released when error occurs in flushablePipe

What fails: The flushablePipe() function in packages/core/src/serialization.ts does not release the reader and writer locks when an error is thrown in the catch block (lines 356-362), leaving the streams in a permanently locked state.

How to reproduce: Create a test scenario where an error occurs during pipe operation:

// Create a readable streamconstsource=newReadableStream({start(controller){controller.enqueue('chunk1');controller.enqueue('chunk2');controller.close();}});// Create a writable stream that errors on second writeletwriteCount=0;constsink=newWritableStream({asyncwrite(chunk){writeCount++;if(writeCount===2){thrownewError('Write failed!');}}});// Call flushablePipe with error stateconststate={pendingOps: 0,doneResolved: false,streamEnded: false,resolve: ()=>{},reject: ()=>{}};try{awaitflushablePipe(source,sink,state);}catch(err){// Error caught}// Attempt to acquire new reader/writersource.getReader();// Throws: "InvalidStateError: ReadableStream is locked"sink.getWriter();// Throws: "InvalidStateError: WritableStream is locked"

Result: Streams remain locked (source.locked === true, sink.locked === true), preventing new readers/writers from being obtained. Cannot acquire new reader: "InvalidStateError: ReadableStream is locked". Cannot acquire new writer: "InvalidStateError: WritableStream is locked".

Expected behavior: The locks should be released before throwing the error, as they are in normal code paths (lines 326, 339, 346). Per WHATWG Streams API specification, a lock must be explicitly released, and a locked stream prevents other readers/writers from being obtained. Leaving locks unreleased creates a resource leak that prevents stream reuse.

Root cause: The catch block at lines 356-362 sets error state and throws without calling reader.releaseLock() and writer.releaseLock(), unlike all other exit paths from the function which properly release locks.

Fix applied: Added reader.releaseLock() and writer.releaseLock() before the throw err statement in the catch block to match the cleanup pattern used in normal code paths.

Comment on lines +329 to +338
if (readResult.done) {
// Source stream completed - close sink and resolve
state.streamEnded = true;
await writer.close();
// Resolve done promise if not already resolved
if (!state.doneResolved) {
state.doneResolved = true;
state.resolve();
}
return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stream locks are not released when the source stream ends. The flushablePipe function acquires locks on the reader and writer but doesn't release them before returning when the readable stream completes.

View Details
📝 Patch Details
diff --git a/packages/core/src/serialization.ts b/packages/core/src/serialization.ts
index c8ebc73..57e4750 100644
--- a/packages/core/src/serialization.ts+++ b/packages/core/src/serialization.ts@@ -335,6 +335,8 @@ async function flushablePipe(
state.doneResolved = true;
state.resolve();
}
+ reader.releaseLock();+ writer.releaseLock();
return;
}
@@ -359,6 +361,8 @@ async function flushablePipe(
state.doneResolved = true;
state.reject(err);
}
+ reader.releaseLock();+ writer.releaseLock();
throw err;
}
}

Analysis

Stream locks not released in flushablePipe() when source completes or errors

What fails: The flushablePipe() function in packages/core/src/serialization.ts (lines 333-341 and 365-371) acquires locks on ReadableStream and WritableStream but fails to release them before returning when the stream completes or when an error occurs.

How to reproduce:

// When readResult.done is true (source stream completed):constreadResult=awaitreader.read();if(readResult.done){state.streamEnded=true;awaitwriter.close();// ... resolve promise ...return;// ❌ Returns without releasing locks}// Or when error occurs:}catch(err){state.streamEnded=true;// ... reject promise ...throwerr;// ❌ Throws without releasing locks}

Result: Unreleased locks prevent any other code from acquiring new readers or writers on these streams. Subsequent attempts to call getReader() or getWriter() will throw TypeError: ReadableStreamDefaultReader constructor can only accept readable streams that are not yet locked to a reader. The stream becomes permanently inaccessible.

Expected behavior: According to MDN Web JavaScript resource management, unreleased locks cause the stream to remain locked indefinitely and "does not permit another reader to consume it." The locks must be explicitly released via reader.releaseLock() and writer.releaseLock() before the function exits.

Fix: Added reader.releaseLock() and writer.releaseLock() calls before all return/throw statements in the flushablePipe function:

  • Before returning when readResult.done (normal stream completion)
  • Before throwing in the catch block (error case)

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements a polling mechanism to detect when stream locks are released, allowing workflow steps to complete without waiting for streams to fully close. This fixes an issue where Vercel functions would hang when users write to streams and release locks without explicitly closing them.

Key Changes:

  • Added flushablePipe function that resolves when either the stream completes or when the user releases their lock with all pending writes flushed
  • Implemented polling functions (pollWritableLock and pollReadableLock) that check stream lock state every 100ms
  • Created state tracking system (FlushableStreamState) to monitor pending operations and resolution status

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 15 comments.

FileDescription
packages/core/src/serialization.tsImplements the core flushable pipe mechanism with polling-based lock detection and state tracking for both readable and writable streams
packages/core/src/serialization.test.tsAdds tests verifying the new behavior for both lock release and natural stream closure scenarios
.changeset/stream-lock-polling.mdDocuments the patch-level change for the @workflow/core package

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +1174 to +1176
flushablePipe(readable, transform.writable, state).catch(() => {
// Errors are handled via state.reject
});

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The flushablePipe function throws errors after calling state.reject() (line 405), but all call sites catch and ignore these thrown errors with empty catch blocks (e.g., line 976, 992, 1016). This pattern is confusing because the error is both rejected through the state AND thrown. Since the error is already propagated via state.reject() which rejects the 'done' promise, the re-throw serves no purpose and the empty catch blocks make this unclear. Consider either removing the throw from flushablePipe or documenting why the error needs to be both rejected and thrown.

Copilot uses AI. Check for mistakes.
Comment on lines +399 to +406
} catch (err) {
state.streamEnded = true;
if (!state.doneResolved) {
state.doneResolved = true;
state.reject(err);
}
throw err;
}

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error handling in the flushablePipe function could potentially leak resources. When an exception occurs in the catch block at line 399, the reader and writer locks are not released before the error is re-thrown. This could leave the streams in a locked state. Consider releasing the locks in the catch block before re-throwing.

Copilot uses AI. Check for mistakes.
Comment on lines +229 to +261
/**
* Checks if a WritableStream is unlocked (user released lock) vs closed.
* When a stream is closed, .locked is false but getWriter() throws.
* We only want to resolve via polling when the stream is unlocked, not closed.
* If closed, the pump will handle resolution via the stream ending naturally.
*/
function isWritableUnlockedNotClosed(writable: WritableStream): boolean {
if (writable.locked) return false;

try {
// Try to acquire writer - if successful, stream is unlocked (not closed)
const writer = writable.getWriter();
writer.releaseLock();
return true;
} catch {
// getWriter() throws if stream is closed/errored - let pump handle it
return false;
}
}

/**
* Checks if a ReadableStream is unlocked (user released lock) vs closed.
*/
function isReadableUnlockedNotClosed(readable: ReadableStream): boolean {
if (readable.locked) return false;

try {
// Try to acquire reader - if successful, stream is unlocked (not closed)
const reader = readable.getReader();
reader.releaseLock();
return true;
} catch {
// getReader() throws if stream is closed/errored - let pump handle it

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The polling functions acquire and release stream locks every 100ms to check if a stream is unlocked vs closed. This repeated lock acquisition could have performance implications and might interfere with stream operations. Consider using a different approach to detect stream closure, such as checking for specific error messages or using a flag that's set when the stream closes, to avoid repeatedly acquiring locks during polling.

Suggested change
/**
*ChecksifaWritableStreamisunlocked(userreleasedlock)vsclosed.
*Whenastreamisclosed,.lockedisfalsebutgetWriter()throws.
*Weonlywanttoresolveviapollingwhenthestreamisunlocked,notclosed.
*Ifclosed,thepumpwillhandleresolutionviathestreamendingnaturally.
*/
functionisWritableUnlockedNotClosed(writable: WritableStream): boolean{
if(writable.locked)returnfalse;
try{
// Try to acquire writer - if successful, stream is unlocked (not closed)
constwriter=writable.getWriter();
writer.releaseLock();
returntrue;
}catch{
// getWriter() throws if stream is closed/errored - let pump handle it
returnfalse;
}
}
/**
*ChecksifaReadableStreamisunlocked(userreleasedlock)vsclosed.
*/
functionisReadableUnlockedNotClosed(readable: ReadableStream): boolean{
if(readable.locked)returnfalse;
try{
// Try to acquire reader - if successful, stream is unlocked (not closed)
constreader=readable.getReader();
reader.releaseLock();
returntrue;
}catch{
// getReader() throws if stream is closed/errored - let pump handle it
/**
*Cacheofper-stream"unlocked but not closed"statusforWritableStreams.
*Thisavoidsrepeatedlyacquiringandreleasinglocksduringpolling;we
*probethestreamatmostonceperinstancewhenittransitionstounlocked.
*/
constwritableUnlockedNotClosedCache=newWeakMap<WritableStream,boolean>();
/**
*ChecksifaWritableStreamisunlocked(userreleasedlock)vsclosed.
*Whenastreamisclosed,.lockedisfalsebutgetWriter()throws.
*Weonlywanttoresolveviapollingwhenthestreamisunlocked,notclosed.
*Ifclosed,thepumpwillhandleresolutionviathestreamendingnaturally.
*
*Toavoidrepeatedlockacquisitionduringpolling,wecachetheresultof
*theunlocked-vs-closedprobeperstream.Oncedetermined,thestatus is
*reusedonsubsequentcallsforthesamestream.
*/
functionisWritableUnlockedNotClosed(writable: WritableStream): boolean{
if(writable.locked)returnfalse;
constcached=writableUnlockedNotClosedCache.get(writable);
if(cached!==undefined){
returncached;
}
try{
// Try to acquire writer - if successful, stream is unlocked (not closed)
constwriter=writable.getWriter();
writer.releaseLock();
writableUnlockedNotClosedCache.set(writable,true);
returntrue;
}catch{
// getWriter() throws if stream is closed/errored - let pump handle it
writableUnlockedNotClosedCache.set(writable,false);
returnfalse;
}
}
/**
*Cacheofper-stream"unlocked but not closed"statusforReadableStreams.
*Likethewritablecache,thispreventsrepeatedreaderacquisitionswhile
*pollingforlockrelease.
*/
constreadableUnlockedNotClosedCache=newWeakMap<ReadableStream,boolean>();
/**
*ChecksifaReadableStreamisunlocked(userreleasedlock)vsclosed.
*Usesaper-streamcachetoavoidrepeatedlyacquiringandreleasinglocks
*duringpolling.
*/
functionisReadableUnlockedNotClosed(readable: ReadableStream): boolean{
if(readable.locked)returnfalse;
constcached=readableUnlockedNotClosedCache.get(readable);
if(cached!==undefined){
returncached;
}
try{
// Try to acquire reader - if successful, stream is unlocked (not closed)
constreader=readable.getReader();
reader.releaseLock();
readableUnlockedNotClosedCache.set(readable,true);
returntrue;
}catch{
// getReader() throws if stream is closed/errored - let pump handle it
readableUnlockedNotClosedCache.set(readable,false);

Copilot uses AI. Check for mistakes.
Comment on lines +1179 to +1186
} catch (err) {
state.streamEnded = true;
if (!state.doneResolved) {
state.doneResolved = true;
state.reject(err as Error);
}
}
})();

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error handling in the test's pipe implementation (line 1179-1185) doesn't release the reader and writer locks before exiting, similar to the issue in the actual flushablePipe implementation. While this mirrors the production code, it would be better to release the locks in the catch block to properly clean up resources. Consider updating the test to match the corrected implementation once the production code is fixed.

Copilot uses AI. Check for mistakes.
Comment on lines +1016 to +1018
flushablePipe(serialize.readable, serverWritable, state).catch(() => {
// Errors are handled via state.reject
});

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The flushablePipe function throws errors after calling state.reject() (line 405), but all call sites catch and ignore these thrown errors with empty catch blocks (e.g., line 976, 992, 1016). This pattern is confusing because the error is both rejected through the state AND thrown. Since the error is already propagated via state.reject() which rejects the 'done' promise, the re-throw serves no purpose and the empty catch blocks make this unclear. Consider either removing the throw from flushablePipe or documenting why the error needs to be both rejected and thrown.

Suggested change
flushablePipe(serialize.readable,serverWritable,state).catch(()=>{
// Errors are handled via state.reject
});
flushablePipe(serialize.readable,serverWritable,state).catch(
(error)=>{
// flushablePipe already calls state.reject(error), which rejects the
// `done` promise added to `ops`. We catch and intentionally ignore
// this rejection here only to avoid an unhandled promise rejection;
// all real error handling flows through state.done.
}
);

Copilot uses AI. Check for mistakes.
Comment on lines +1001 to +1229
describe('flushable stream behavior', () => {
const POLL_INTERVAL = 100; // Match the actual implementation
const STABLE_POLL_COUNT = 2; // Match the actual implementation

it('done promise should resolve when writable stream lock is released (polling)', async () => {
// Test the pattern: user writes, releases lock, polling detects it, done resolves
const chunks: string[] = [];
let streamClosed = false;

// Create a simple mock for the sink
const mockSink = new WritableStream<string>({
write(chunk) {
chunks.push(chunk);
},
close() {
streamClosed = true;
},
});

// Create a TransformStream like we do in getStepRevivers
const { readable, writable } = new TransformStream<string, string>();

// Track flushable state - this mirrors the actual implementation
const state = {
pendingOps: 0, // Only counts writes to server
doneResolved: false,
streamEnded: false,
resolve: () => {},
reject: (_err: Error) => {},
};

const done = new Promise<void>((res, rej) => {
state.resolve = res;
state.reject = rej;
});

// Start piping in background (mirrors flushablePipe implementation)
(async () => {
const reader = readable.getReader();
const writer = mockSink.getWriter();
try {
while (!state.streamEnded) {
const result = await reader.read();

if (result.done) {
state.streamEnded = true;
await writer.close();
if (!state.doneResolved) {
state.doneResolved = true;
state.resolve();
}
return;
}

// Only writes count as pending ops
state.pendingOps++;
await writer.write(result.value);
state.pendingOps--;

if (state.streamEnded) {
reader.releaseLock();
writer.releaseLock();
return;
}
}
} catch (err) {
state.streamEnded = true;
if (!state.doneResolved) {
state.doneResolved = true;
state.reject(err as Error);
}
}
})();

// Start polling (mirrors pollWritableLock implementation)
let stableCount = 0;
const intervalId = setInterval(() => {
if (state.doneResolved || state.streamEnded) {
clearInterval(intervalId);
return;
}

// Check if lock is released by checking .locked property
if (!writable.locked && state.pendingOps === 0) {
stableCount++;
if (stableCount >= STABLE_POLL_COUNT) {
state.doneResolved = true;
state.resolve();
clearInterval(intervalId);
}
} else {
stableCount = 0;
}
}, POLL_INTERVAL);

// Simulate user interaction - write and release lock
const userWriter = writable.getWriter();
await userWriter.write('chunk1');
await userWriter.write('chunk2');

// Release lock without closing stream
userWriter.releaseLock();

// Wait for pipe to process + polling intervals (need STABLE_POLL_COUNT consecutive polls)
await new Promise((r) => setTimeout(r, 250));

// The done promise should resolve
await expect(
Promise.race([
done,
new Promise((_, r) => setTimeout(() => r(new Error('timeout')), 400)),
])
).resolves.toBeUndefined();

// Chunks should have been written
expect(chunks).toContain('chunk1');
expect(chunks).toContain('chunk2');

// Stream should NOT be closed (user only released lock)
expect(streamClosed).toBe(false);
});

it('done promise should resolve when writable stream closes naturally', async () => {
const chunks: string[] = [];
let streamClosed = false;

const mockSink = new WritableStream<string>({
write(chunk) {
chunks.push(chunk);
},
close() {
streamClosed = true;
},
});

const { readable, writable } = new TransformStream<string, string>();

const state = {
pendingOps: 0,
doneResolved: false,
streamEnded: false,
resolve: () => {},
reject: (_err: Error) => {},
};

const done = new Promise<void>((res, rej) => {
state.resolve = res;
state.reject = rej;
});

// Start piping in background
(async () => {
const reader = readable.getReader();
const writer = mockSink.getWriter();
try {
while (!state.streamEnded) {
const result = await reader.read();

if (result.done) {
state.streamEnded = true;
await writer.close();
if (!state.doneResolved) {
state.doneResolved = true;
state.resolve();
}
return;
}

state.pendingOps++;
await writer.write(result.value);
state.pendingOps--;

if (state.streamEnded) {
reader.releaseLock();
writer.releaseLock();
return;
}
}
} catch (err) {
state.streamEnded = true;
if (!state.doneResolved) {
state.doneResolved = true;
state.reject(err as Error);
}
}
})();

// Start polling (won't trigger since stream will close first)
let stableCount2 = 0;
const intervalId = setInterval(() => {
if (state.doneResolved || state.streamEnded) {
clearInterval(intervalId);
return;
}
if (!writable.locked && state.pendingOps === 0) {
stableCount2++;
if (stableCount2 >= STABLE_POLL_COUNT) {
state.doneResolved = true;
state.resolve();
clearInterval(intervalId);
}
} else {
stableCount2 = 0;
}
}, POLL_INTERVAL);

// User writes and then closes the stream
const userWriter = writable.getWriter();
await userWriter.write('data');
await userWriter.close();

// Wait a tick for the pipe to process
await new Promise((r) => setTimeout(r, 50));

// The done promise should resolve
await expect(
Promise.race([
done,
new Promise((_, r) => setTimeout(() => r(new Error('timeout')), 200)),
])
).resolves.toBeUndefined();

// Chunks should have been written
expect(chunks).toContain('data');

// Stream should be closed (user closed it)
expect(streamClosed).toBe(true);
});
});

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new flushable stream behavior tests only cover the happy paths (lock release and normal closure). Consider adding test cases for error scenarios such as: stream errors during write operations, reader errors, and what happens when the stream is aborted. This would help ensure the error handling in flushablePipe and the polling mechanisms work correctly under failure conditions.

Copilot uses AI. Check for mistakes.
Comment on lines +1158 to +1160
flushablePipe(readable, writable, state).catch(() => {
// Errors are handled via state.reject
});

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The flushablePipe function throws errors after calling state.reject() (line 405), but all call sites catch and ignore these thrown errors with empty catch blocks (e.g., line 976, 992, 1016). This pattern is confusing because the error is both rejected through the state AND thrown. Since the error is already propagated via state.reject() which rejects the 'done' promise, the re-throw serves no purpose and the empty catch blocks make this unclear. Consider either removing the throw from flushablePipe or documenting why the error needs to be both rejected and thrown.

Copilot uses AI. Check for mistakes.
Comment on lines +192 to +196
* - `doneResolved`: The `done` promise has been resolved (step can complete)
* - `streamEnded`: The underlying stream has actually closed/errored
*
* The pump continues running even after `doneResolved=true` to handle
* any future writes if the user acquires a new lock.

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment states that "The pump continues running even after doneResolved=true to handle any future writes if the user acquires a new lock." However, this design could be problematic. Once the done promise resolves (pushed to ops), the workflow step will complete. If the user then acquires a new lock and writes more data after the step has completed, it could lead to unexpected behavior where data is being written to a stream associated with an already-completed step. Consider whether this is the intended behavior or if the stream should be closed/locked after the done promise resolves.

Suggested change
*-`doneResolved`: The`done`promisehasbeenresolved(stepcancomplete)
*-`streamEnded`: Theunderlyingstreamhasactuallyclosed/errored
*
*Thepumpcontinuesrunningevenafter`doneResolved=true`tohandle
* any futurewritesiftheuseracquiresanewlock.
*-`doneResolved`: The`done`promisehasbeenresolved(stepcancomplete),
*andnofurtherwritesshouldbeacceptedforthisstream.
*-`streamEnded`: Theunderlyingstreamhasactuallyclosed/errored.
*
*Once`doneResolved`becomes`true`,thepumpshouldonlydrainany
*already-pendingoperationsandthenstop;itmustnothandlenewwrites
*fromsubsequentlyacquiredlocks.

Copilot uses AI. Check for mistakes.
Comment on lines +1066 to +1073
} catch (err) {
state.streamEnded = true;
if (!state.doneResolved) {
state.doneResolved = true;
state.reject(err as Error);
}
}
})();

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error handling in the test's pipe implementation (line 1066-1072) doesn't release the reader and writer locks before exiting, similar to the issue in the actual flushablePipe implementation. While this mirrors the production code, it would be better to release the locks in the catch block to properly clean up resources. Consider updating the test to match the corrected implementation once the production code is fixed.

Copilot uses AI. Check for mistakes.
Comment on lines +1189 to +1203
let stableCount2 = 0;
const intervalId = setInterval(() => {
if (state.doneResolved || state.streamEnded) {
clearInterval(intervalId);
return;
}
if (!writable.locked && state.pendingOps === 0) {
stableCount2++;
if (stableCount2 >= STABLE_POLL_COUNT) {
state.doneResolved = true;
state.resolve();
clearInterval(intervalId);
}
} else {
stableCount2 = 0;

CopilotAIDec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The variable name stableCount2 is unclear and suggests copy-paste from the first test. Consider renaming to stableCount for consistency, as the numbering doesn't add clarity and makes the code harder to read.

Suggested change
letstableCount2=0;
constintervalId=setInterval(()=>{
if(state.doneResolved||state.streamEnded){
clearInterval(intervalId);
return;
}
if(!writable.locked&&state.pendingOps===0){
stableCount2++;
if(stableCount2>=STABLE_POLL_COUNT){
state.doneResolved=true;
state.resolve();
clearInterval(intervalId);
}
}else{
stableCount2=0;
letstableCount=0;
constintervalId=setInterval(()=>{
if(state.doneResolved||state.streamEnded){
clearInterval(intervalId);
return;
}
if(!writable.locked&&state.pendingOps===0){
stableCount++;
if(stableCount>=STABLE_POLL_COUNT){
state.doneResolved=true;
state.resolve();
clearInterval(intervalId);
}
}else{
stableCount=0;

Copilot uses AI. Check for mistakes.

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before I review - there's a ton of AI comments on this PR and I want to give you a chance to resolve those, so I'm subscribing and re-reviewing after the next commit/message

Base automatically changed from 12-22-fix_race_conditions_in_webhook_response_handling to mainDecember 22, 2025 18:41
@TooTallNate

Copy link
Copy Markdown
MemberAuthor

@copilot Please evaluate the code review suggestions in this PR and apply them if you think they are relevent.

CopilotAI commented Dec 22, 2025

Copy link
Copy Markdown
Contributor

@TooTallNate I've opened a new pull request, #677, to work on those changes. Once the pull request is ready, I'll request review from you.

…f waiting for stream to close
This prevents Vercel functions from hanging when users incrementally write to streams within steps (e.g., `await writer.write(data); writer.releaseLock()`). Uses a polling approach to detect when the stream lock is released and all pending writes are flushed.
Comment on lines +271 to +316
function pollWritableLock(
writable: WritableStream,
state: FlushableStreamState
): void {
const intervalId = setInterval(() => {
// Stop polling if already resolved or stream ended
if (state.doneResolved || state.streamEnded) {
clearInterval(intervalId);
return;
}

// Check if lock is released (not closed) and no pending ops
if (isWritableUnlockedNotClosed(writable) && state.pendingOps === 0) {
state.doneResolved = true;
state.resolve();
clearInterval(intervalId);
}
}, LOCK_POLL_INTERVAL_MS);
}

/**
* Polls a ReadableStream to check if the user has released their lock.
* Resolves the done promise when lock is released and no pending ops remain.
*
* Note: Only resolves if stream is unlocked but NOT closed. If the user closes
* the stream, the pump will handle resolution via the stream ending naturally.
*/
function pollReadableLock(
readable: ReadableStream,
state: FlushableStreamState
): void {
const intervalId = setInterval(() => {
// Stop polling if already resolved or stream ended
if (state.doneResolved || state.streamEnded) {
clearInterval(intervalId);
return;
}

// Check if lock is released (not closed) and no pending ops
if (isReadableUnlockedNotClosed(readable) && state.pendingOps === 0) {
state.doneResolved = true;
state.resolve();
clearInterval(intervalId);
}
}, LOCK_POLL_INTERVAL_MS);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The polling functions don't implement the "multiple consecutive polls" behavior mentioned in the docstring, creating a potential race condition where the done promise resolves before the pump finishes writing queued data.

View Details
📝 Patch Details
diff --git a/packages/core/src/serialization.ts b/packages/core/src/serialization.ts
index 69c391b..376afe4 100644
--- a/packages/core/src/serialization.ts+++ b/packages/core/src/serialization.ts@@ -182,6 +182,9 @@ export class WorkflowServerWritableStream extends WritableStream<Uint8Array> {
/** Polling interval for checking stream lock state (in milliseconds) */
const LOCK_POLL_INTERVAL_MS = 100;
+/** Number of consecutive stable polls before resolving done promise */+const STABLE_POLL_COUNT = 2;+
/**
* State tracker for flushable stream operations.
* Resolves when either:
@@ -272,6 +275,7 @@ function pollWritableLock(
writable: WritableStream,
state: FlushableStreamState
): void {
+ let stableCount = 0;
const intervalId = setInterval(() => {
// Stop polling if already resolved or stream ended
if (state.doneResolved || state.streamEnded) {
@@ -281,16 +285,22 @@ function pollWritableLock(
// Check if lock is released (not closed) and no pending ops
if (isWritableUnlockedNotClosed(writable) && state.pendingOps === 0) {
- state.doneResolved = true;- state.resolve();- clearInterval(intervalId);+ stableCount++;+ if (stableCount >= STABLE_POLL_COUNT) {+ state.doneResolved = true;+ state.resolve();+ clearInterval(intervalId);+ }+ } else {+ stableCount = 0;
}
}, LOCK_POLL_INTERVAL_MS);
}
/**
* Polls a ReadableStream to check if the user has released their lock.
- * Resolves the done promise when lock is released and no pending ops remain.+ * Resolves the done promise when lock is released and no pending ops remain+ * for multiple consecutive polls (to avoid race conditions with the pump).
*
* Note: Only resolves if stream is unlocked but NOT closed. If the user closes
* the stream, the pump will handle resolution via the stream ending naturally.
@@ -299,6 +309,7 @@ function pollReadableLock(
readable: ReadableStream,
state: FlushableStreamState
): void {
+ let stableCount = 0;
const intervalId = setInterval(() => {
// Stop polling if already resolved or stream ended
if (state.doneResolved || state.streamEnded) {
@@ -308,9 +319,14 @@ function pollReadableLock(
// Check if lock is released (not closed) and no pending ops
if (isReadableUnlockedNotClosed(readable) && state.pendingOps === 0) {
- state.doneResolved = true;- state.resolve();- clearInterval(intervalId);+ stableCount++;+ if (stableCount >= STABLE_POLL_COUNT) {+ state.doneResolved = true;+ state.resolve();+ clearInterval(intervalId);+ }+ } else {+ stableCount = 0;
}
}, LOCK_POLL_INTERVAL_MS);
}

Analysis

Race condition in polling functions: done promise resolves before pump finishes flushing

What fails: The pollWritableLock() and pollReadableLock() functions in packages/core/src/serialization.ts resolve the done promise immediately when the stream lock is released and pendingOps === 0, without waiting for multiple consecutive stable polls. This creates a race condition where the done promise can resolve while the pump is still reading/writing data.

How to reproduce:

  1. A stream is being piped asynchronously via flushablePipe()
  2. User releases the lock on the stream (but doesn't close it)
  3. Pump is momentarily between reads (at await reader.read()) with pendingOps === 0
  4. Polling fires and sees: lock released + pendingOps === 0 → resolves done promise immediately
  5. Runtime awaits Promise.all(ops) which includes the done promise and proceeds
  6. If pump reads more data and tries to write, the step is already marked complete
  7. This violates the contract that all writes must be flushed before the step completes

Expected behavior: Per the docstring at lines 265-266 and 293-294, polling should only resolve after the condition is stable for "multiple consecutive polls (to avoid race conditions with the pump)." The test file at lines 1002-1003 and 1084-1090 confirms this behavior by using STABLE_POLL_COUNT = 2 - only resolving after the unlock condition is seen 2 consecutive times.

Root cause: The actual implementation was missing the stability counter logic that the docstring promised and that the test expected. Added STABLE_POLL_COUNT constant and stability tracking to both polling functions.

Evidence:

  • Docstring promises: "for multiple consecutive polls (to avoid race conditions with the pump)"
  • Test implementation (lines 1084-1090): Implements stability counter that resets when condition changes
  • Test comment (line 1001): "mirrors the actual implementation"
  • Git history: Commit e8c3800 added polling with docstring but without stability logic

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@TooTallNate@VaguelySerious