Skip to content

[core] Ack'd write-channel stream writes with framed-v2 writer markers#2731

Open
VaguelySerious wants to merge 24 commits into
mainfrom
peter/streaming-writes
Open

[core] Ack'd write-channel stream writes with framed-v2 writer markers#2731
VaguelySerious wants to merge 24 commits into
mainfrom
peter/streaming-writes

Conversation

@VaguelySerious

@VaguelySerious VaguelySerious commented Jun 30, 2026

Copy link
Copy Markdown
Member

What

Stream writes move from one short PUT per 10 ms flush batch to a long-lived, acknowledged write channel (WebSocket), with a new framed-v2 wire format that stamps every frame with a per-writer marker (writerId + seq).

  • Core stays world-agnostic: there is one write path — the buffered sink flushing through writeMulti. When frames carry framed-v2 markers, the flush passes { retransmitSafe: true } (new optional StreamWriteOptions on writeMulti), granting the world permission to use a transport that resends unconfirmed chunks across reconnects, since readers can deduplicate the overlap. Worlds that ignore the option keep exactly their current behavior. Aborting a writable tears down the world's transport state via the new optional streams.abort.
  • world-vercel owns the transport choice: retransmit-safe batches ride a long-lived WebSocket channel (one writer per stream) — each chunk is one binary message, persisted and published by the backend on arrival and acked back {index, chunkIndex} in order; close() drains all pending acks before sending the done marker. Everything else uses the batched PUT.
  • StreamSocketWriter (in world-vercel): evicts its replay buffer per ack (memory bounded by the in-flight window — 64 frames / 4 MB, deliberately under the server's 100-chunk per-connection backlog cap so a burst can never enter a shed→resend→shed loop), recycles connections proactively (~110 s), and survives unclean closes by resending unacked frames on a fresh channel, under consecutive + lifetime reconnect budgets. A 1009 close (chunk larger than the server's message cap) fails fast with a chunk-size error instead of burning the reconnect budget on a deterministic rejection. All tuning knobs are env-overridable (WORKFLOW_STREAM_WRITE_*).
  • framed-v2 everywhere it applies: getWritable() object streams and byte streams (step-returned / workflow-argument ReadableStreams) emit framed-v2 when the target run's capability allows; read-side dedupe by (writerId, seq) makes resend overlap exactly-once, including with concurrent writers to one stream (forwarded writables mint their own writerId; framing is per-stream, carried in descriptors and through the workflow VM round-trip). Below the capability cutoff everything stays framed-v1/raw + batch writes with no retransmit grant — byte-identical legacy behavior.
  • Durability semantics: writeMulti resolves on window admission (flushes pipeline instead of stop-and-wait); durability is confirmed by acks, close() resolves only after a full drain, and each admitted batch registers an ack barrier with the platform's waitUntil so an invocation is never suspended while frames await confirmation.
  • Observability: the WS upgrade is a client span and carries W3C trace context like every other backend request (chunks on the socket have no per-message headers, so the upgrade is where the trace link is established). Covered in trace-propagation.test.ts.
  • All world-vercel stream PUTs (write/writeMulti fallback/close) move from the v2 to the v3 endpoint (identical handler semantics; makes rollout and request-log analysis unambiguous). The v2 write path is removed.

Why

Today every active writer costs ~100 requests/second and a chunk is only durable-confirmed when its batch's PUT resolves. A streaming request body can't fix this on deployed infra (bodies are delivered to functions only at close — verified empirically), while WS messages arrive incrementally: the deployed probe measured p50 75 ms chunk-to-ack, with live readers seeing chunks immediately via per-chunk publish.

Throughput note: within one connection the backend persists chunks serially (reserve → write → publish per chunk), where the PUT batch path parallelized storage writes within a batch. In exchange the writer pipelines continuously instead of stop-and-waiting one round trip per 10 ms batch, so sustained throughput improves for realistic producers; only extreme small-chunk producers trade peak burst rate for the lower request overhead.

CI activation (no dark launch)

The framedStreamMarkers capability cutoff is set to 5.0.0-beta.26 — at/below the tree's version — so every unit and e2e lane on this PR exercises framed-v2 + the WS write channel, on both the local worlds (framed-v2 over batch writes) and the Vercel lanes (framed-v2 over the write channel against the backend preview). There is no test-only env flag.

Merge checklist (ordered)

  1. Backend PR deploys first — this SDK writes to the v3 stream PUT route and the /ws upgrade route added there; merging this PR before that deploy breaks all stream writes.
  2. Revert WORKFLOW_SERVER_URL_OVERRIDE to '' in packages/world-vercel/src/utils.ts (the "No Test Overrides" check enforces this; it is expected-red until then).
  3. Bump the framedStreamMarkers cutoff to the first beta that actually ships this (currently the next one, beta.28beta.26/beta.27 are published without marker support; the too-low cutoff is only safe on this branch, where writer and reader are always built together). A TODO(release) marks the line.

Validation

  • Unit: core + world-vercel suites green, including the socket writer's reconnect/resend/rotation/budget matrix (with regression tests for the rotation-vs-drain race and 1009 fail-fast), ack-barrier semantics, env-knob resolution, the retransmitSafe grant contract, writer eviction on fatal failure, WS-upgrade trace propagation, the getWritable framed-v2 round-trip, and byte-stream framed-v2 dehydrate→wire→unframe round-trips with duplicate-frame dedupe.
  • Deployed probes against the world-vercel backend preview: 30-frame slow-drip over the real channel — 30/30 acks in order, contiguous chunk indices, live reader sees every chunk while the writer is still open; batched v3 PUT probe (writeMulti/write/close) persists and reads back all chunks.

🤖 Generated with Claude Code

Groundwork for single-request streaming stream writes. Adds an opt-in
framed-v2 frame header carrying a per-writer marker ([writerId][seq])
to both the byte framer/unframer and the object serialize/deserialize
streams, plus a shared marker codec. The reader strips the marker and
dedupes replays by max-seq-per-writerId, so a frame recovery re-sends
after it was already persisted is delivered exactly once.

Not yet wired into any writer (no writerId is passed today), so there
is no user-facing behavior change. Capability gating + the streaming
segment writer + tail-match recovery follow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Jun 30, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 94be32c

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

This PR includes changesets to release 20 packages
Name Type
@workflow/core Minor
@workflow/world Minor
@workflow/world-vercel Minor
@workflow/builders Patch
@workflow/cli Patch
@workflow/next Patch
@workflow/nitro Patch
@workflow/vitest Patch
@workflow/web-shared Patch
@workflow/web Patch
workflow Minor
@workflow/world-testing Patch
@workflow/world-local Patch
@workflow/world-postgres Patch
@workflow/astro Patch
@workflow/nest Patch
@workflow/nuxt Patch
@workflow/rollup Patch
@workflow/sveltekit Patch
@workflow/vite Patch

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

@vercel

vercel Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
example-nextjs-workflow-turbopack Ready Ready Preview, Comment Jul 6, 2026 11:57pm
example-nextjs-workflow-webpack Ready Ready Preview, Comment Jul 6, 2026 11:57pm
example-workflow Ready Ready Preview, Comment Jul 6, 2026 11:57pm
workbench-astro-workflow Ready Ready Preview, Comment Jul 6, 2026 11:57pm
workbench-express-workflow Ready Ready Preview, Comment Jul 6, 2026 11:57pm
workbench-fastify-workflow Ready Ready Preview, Comment Jul 6, 2026 11:57pm
workbench-hono-workflow Ready Ready Preview, Comment Jul 6, 2026 11:57pm
workbench-nitro-workflow Ready Ready Preview, Comment Jul 6, 2026 11:57pm
workbench-nuxt-workflow Ready Ready Preview, Comment Jul 6, 2026 11:57pm
workbench-sveltekit-workflow Ready Ready Preview, Comment Jul 6, 2026 11:57pm
workbench-tanstack-start-workflow Ready Ready Preview, Comment Jul 6, 2026 11:57pm
workbench-vite-workflow Ready Ready Preview, Comment Jul 6, 2026 11:57pm
workflow-docs Ready Ready Preview, Comment, Open in v0 Jul 6, 2026 11:57pm
workflow-swc-playground Ready Ready Preview, Comment Jul 6, 2026 11:57pm
workflow-tarballs Ready Ready Preview, Comment Jul 6, 2026 11:57pm
workflow-web Ready Ready Preview, Comment Jul 6, 2026 11:57pm

@github-actions

github-actions Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

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

workflow with no steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Nitro 0.047s (+1.1%) 1.006s (~) 0.959s 10 1.00x
💻 Local Express 0.047s (+5.1% 🔺) 1.006s (~) 0.959s 10 1.00x
💻 Local Next.js (Turbopack) 0.056s (+5.3% 🔺) 1.006s (~) 0.950s 10 1.19x
🐘 Postgres Nitro 0.063s (-4.0%) 1.012s (~) 0.949s 10 1.34x
🐘 Postgres Express ⚠️ missing - - - -
🐘 Postgres Next.js (Turbopack) ⚠️ missing - - - -

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 0.190s (+1.2%) 1.517s (-25.2% 🟢) 1.327s 10 1.00x
▲ Vercel Express 0.216s (-4.0%) 1.935s (-5.5% 🟢) 1.719s 10 1.13x
▲ Vercel Next.js (Turbopack) 0.353s (+45.3% 🔺) 2.155s (-9.4% 🟢) 1.801s 10 1.86x

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

workflow with 1 step

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Nitro 1.082s (~) 2.007s (~) 0.924s 10 1.00x
🐘 Postgres Nitro 1.089s (-0.9%) 2.010s (~) 0.921s 10 1.01x
💻 Local Express 1.090s (~) 2.006s (~) 0.916s 10 1.01x
💻 Local Next.js (Turbopack) 1.095s (~) 2.007s (~) 0.912s 10 1.01x
🐘 Postgres Express ⚠️ missing - - - -
🐘 Postgres Next.js (Turbopack) ⚠️ missing - - - -

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 1.383s (-2.4%) 2.877s (+1.9%) 1.493s 10 1.00x
▲ Vercel Nitro 1.437s (+5.5% 🔺) 3.147s (+4.3%) 1.710s 10 1.04x
▲ Vercel Next.js (Turbopack) 2.013s (-14.2% 🟢) 3.645s (+1.5%) 1.632s 10 1.45x

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

workflow with 10 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 10.462s (~) 11.019s (~) 0.557s 3 1.00x
💻 Local Nitro 10.468s (~) 11.022s (~) 0.553s 3 1.00x
💻 Local Express 10.475s (~) 11.022s (~) 0.548s 3 1.00x
💻 Local Next.js (Turbopack) 10.542s (~) 11.024s (~) 0.482s 3 1.01x
🐘 Postgres Express ⚠️ missing - - - -
🐘 Postgres Next.js (Turbopack) ⚠️ missing - - - -

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 11.909s (+2.0%) 13.502s (-3.2%) 1.593s 3 1.00x
▲ Vercel Nitro 11.958s (+2.3%) 13.894s (+9.4% 🔺) 1.936s 3 1.00x
▲ Vercel Next.js (Turbopack) 12.707s (-1.7%) 14.895s (+5.1% 🔺) 2.188s 3 1.07x

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

workflow with 25 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 13.578s (-0.9%) 14.019s (~) 0.441s 5 1.00x
💻 Local Express 13.626s (-0.5%) 14.027s (~) 0.401s 5 1.00x
💻 Local Nitro 13.720s (~) 14.027s (~) 0.307s 5 1.01x
💻 Local Next.js (Turbopack) 13.812s (+0.7%) 14.026s (~) 0.214s 5 1.02x
🐘 Postgres Express ⚠️ missing - - - -
🐘 Postgres Next.js (Turbopack) ⚠️ missing - - - -

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 16.282s (+0.6%) 18.436s (+5.7% 🔺) 2.154s 4 1.00x
▲ Vercel Express 16.684s (~) 18.708s (-1.3%) 2.024s 4 1.02x
▲ Vercel Next.js (Turbopack) 17.774s (-1.0%) 19.938s (+0.9%) 2.164s 4 1.09x

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

workflow with 50 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 12.166s (-1.4%) 13.019s (~) 0.852s 7 1.00x
💻 Local Nitro 12.261s (~) 13.025s (~) 0.764s 7 1.01x
💻 Local Express 12.273s (~) 13.025s (~) 0.751s 7 1.01x
💻 Local Next.js (Turbopack) 12.650s (+3.3%) 13.026s (~) 0.376s 7 1.04x
🐘 Postgres Express ⚠️ missing - - - -
🐘 Postgres Next.js (Turbopack) ⚠️ missing - - - -

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 18.779s (+10.0% 🔺) 20.769s (+10.6% 🔺) 1.989s 5 1.00x
▲ Vercel Express 18.985s (+9.8% 🔺) 20.846s (+8.7% 🔺) 1.861s 5 1.01x
▲ Vercel Next.js (Turbopack) 20.594s (+4.5%) 22.697s (+4.7%) 2.104s 4 1.10x

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

Promise.all with 10 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 1.177s (-0.8%) 2.008s (~) 0.830s 15 1.00x
💻 Local Nitro 1.401s (~) 2.006s (~) 0.606s 15 1.19x
💻 Local Express 1.413s (-2.2%) 2.006s (~) 0.593s 15 1.20x
💻 Local Next.js (Turbopack) 1.430s (+1.6%) 2.007s (~) 0.577s 15 1.21x
🐘 Postgres Express ⚠️ missing - - - -
🐘 Postgres Next.js (Turbopack) ⚠️ missing - - - -

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 2.157s (-5.7% 🟢) 3.918s (+8.7% 🔺) 1.761s 8 1.00x
▲ Vercel Express 2.284s (+0.5%) 3.781s (-3.1%) 1.497s 8 1.06x
▲ Vercel Next.js (Turbopack) 3.723s (+26.7% 🔺) 5.365s (+15.6% 🔺) 1.642s 6 1.73x

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

Promise.all with 25 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 1.344s (-3.9%) 2.392s (-3.2%) 1.048s 13 1.00x
💻 Local Express 2.341s (-11.9% 🟢) 3.008s (-3.2%) 0.667s 10 1.74x
💻 Local Next.js (Turbopack) 2.520s (+1.3%) 3.009s (-3.2%) 0.489s 10 1.87x
💻 Local Nitro 2.534s (+9.3% 🔺) 3.009s (~) 0.474s 10 1.89x
🐘 Postgres Express ⚠️ missing - - - -
🐘 Postgres Next.js (Turbopack) ⚠️ missing - - - -

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 2.334s (-10.4% 🟢) 3.676s (-10.7% 🟢) 1.342s 9 1.00x
▲ Vercel Nitro 2.809s (+11.1% 🔺) 4.381s (+23.1% 🔺) 1.572s 7 1.20x
▲ Vercel Next.js (Turbopack) 5.296s (+59.1% 🔺) 6.837s (+39.3% 🔺) 1.541s 5 2.27x

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

Promise.all with 50 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 1.584s (-2.7%) 4.137s (~) 2.553s 8 1.00x
💻 Local Nitro 4.695s (+9.4% 🔺) 5.015s (+2.9%) 0.320s 6 2.96x
💻 Local Express 4.737s (+7.3% 🔺) 5.180s (~) 0.443s 6 2.99x
💻 Local Next.js (Turbopack) 4.933s (+5.0%) 5.350s (-3.0%) 0.418s 6 3.11x
🐘 Postgres Express ⚠️ missing - - - -
🐘 Postgres Next.js (Turbopack) ⚠️ missing - - - -

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 3.199s (+9.6% 🔺) 5.111s (+16.4% 🔺) 1.913s 6 1.00x
▲ Vercel Nitro 3.417s (+20.8% 🔺) 5.262s (+16.7% 🔺) 1.845s 6 1.07x
▲ Vercel Next.js (Turbopack) 6.266s (+58.6% 🔺) 8.281s (+43.5% 🔺) 2.015s 4 1.96x

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

Promise.race with 10 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 1.198s (~) 2.007s (~) 0.809s 15 1.00x
💻 Local Nitro 1.450s (+2.5%) 2.008s (~) 0.558s 15 1.21x
💻 Local Express 1.458s (~) 2.007s (~) 0.549s 15 1.22x
💻 Local Next.js (Turbopack) 1.506s (~) 2.007s (~) 0.501s 15 1.26x
🐘 Postgres Express ⚠️ missing - - - -
🐘 Postgres Next.js (Turbopack) ⚠️ missing - - - -

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 2.123s (+11.8% 🔺) 3.872s (+15.7% 🔺) 1.749s 8 1.00x
▲ Vercel Express 2.192s (+13.4% 🔺) 3.768s (+3.1%) 1.576s 8 1.03x
▲ Vercel Next.js (Turbopack) 3.260s (+9.6% 🔺) 5.322s (+13.1% 🔺) 2.062s 6 1.54x

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

Promise.race with 25 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 1.352s (+2.5%) 2.316s (+15.4% 🔺) 0.964s 13 1.00x
💻 Local Next.js (Turbopack) 2.576s (-8.3% 🟢) 3.009s (-10.0% 🟢) 0.432s 10 1.91x
💻 Local Express 2.619s (+5.2% 🔺) 3.008s (~) 0.390s 10 1.94x
💻 Local Nitro 2.661s (+8.9% 🔺) 3.108s (+3.3%) 0.447s 10 1.97x
🐘 Postgres Express ⚠️ missing - - - -
🐘 Postgres Next.js (Turbopack) ⚠️ missing - - - -

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 2.357s (+7.3% 🔺) 3.922s (+11.9% 🔺) 1.564s 8 1.00x
▲ Vercel Express 2.442s (-21.3% 🟢) 4.163s (-9.1% 🟢) 1.720s 8 1.04x
▲ Vercel Next.js (Turbopack) 4.099s (+29.9% 🔺) 6.068s (+33.7% 🔺) 1.969s 5 1.74x

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

Promise.race with 50 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 1.575s (-3.6%) 4.261s (-0.9%) 2.686s 8 1.00x
💻 Local Nitro 5.057s (-8.4% 🟢) 6.216s (+3.3%) 1.159s 5 3.21x
💻 Local Express 5.139s (+44.1% 🔺) 6.214s (+24.0% 🔺) 1.075s 5 3.26x
💻 Local Next.js (Turbopack) 5.578s (+3.2%) 6.017s (~) 0.438s 6 3.54x
🐘 Postgres Express ⚠️ missing - - - -
🐘 Postgres Next.js (Turbopack) ⚠️ missing - - - -

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 2.678s (+0.6%) 4.429s (~) 1.751s 7 1.00x
▲ Vercel Nitro 4.307s (+69.1% 🔺) 6.184s (+62.5% 🔺) 1.877s 5 1.61x
▲ Vercel Next.js (Turbopack) 4.613s (+2.3%) 6.472s (+5.4% 🔺) 1.860s 5 1.72x

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

workflow with 10 sequential data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 0.553s (-8.1% 🟢) 1.023s (~) 0.471s 59 1.00x
💻 Local Nitro 0.612s (+4.8%) 1.005s (~) 0.393s 60 1.11x
💻 Local Express 0.618s (+2.1%) 1.005s (~) 0.387s 60 1.12x
💻 Local Next.js (Turbopack) 0.643s (+7.3% 🔺) 1.005s (~) 0.363s 60 1.16x
🐘 Postgres Express ⚠️ missing - - - -
🐘 Postgres Next.js (Turbopack) ⚠️ missing - - - -

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 2.372s (-9.0% 🟢) 4.126s (+2.8%) 1.755s 15 1.00x
▲ Vercel Express 2.436s (-2.1%) 4.111s (+3.6%) 1.676s 15 1.03x
▲ Vercel Next.js (Turbopack) 3.600s (-11.2% 🟢) 5.619s (-0.7%) 2.019s 11 1.52x

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

workflow with 25 sequential data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 1.288s (-7.6% 🟢) 2.029s (~) 0.741s 45 1.00x
💻 Local Express 1.499s (~) 2.006s (~) 0.507s 45 1.16x
💻 Local Nitro 1.521s (+5.1% 🔺) 2.006s (~) 0.485s 45 1.18x
💻 Local Next.js (Turbopack) 1.584s (+0.7%) 2.006s (~) 0.422s 45 1.23x
🐘 Postgres Express ⚠️ missing - - - -
🐘 Postgres Next.js (Turbopack) ⚠️ missing - - - -

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 5.680s (-2.8%) 7.542s (+2.2%) 1.862s 12 1.00x
▲ Vercel Express 6.107s (+4.9%) 7.800s (+4.3%) 1.694s 12 1.08x
▲ Vercel Next.js (Turbopack) 8.257s (~) 10.174s (+6.1% 🔺) 1.917s 9 1.45x

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

workflow with 50 sequential data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 2.611s (-8.3% 🟢) 3.111s (-3.4%) 0.500s 39 1.00x
💻 Local Nitro 3.328s (+4.7%) 4.009s (+1.6%) 0.682s 30 1.27x
💻 Local Express 3.338s (~) 4.009s (~) 0.671s 30 1.28x
💻 Local Next.js (Turbopack) 3.483s (+1.5%) 4.044s (+0.9%) 0.561s 30 1.33x
🐘 Postgres Express ⚠️ missing - - - -
🐘 Postgres Next.js (Turbopack) ⚠️ missing - - - -

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 11.394s (-0.8%) 13.298s (+2.8%) 1.904s 10 1.00x
▲ Vercel Express 11.413s (-2.4%) 13.344s (-3.5%) 1.931s 10 1.00x
▲ Vercel Next.js (Turbopack) 17.554s (+2.4%) 19.756s (+5.6% 🔺) 2.202s 7 1.54x

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

workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 0.223s (+0.9%) 1.006s (~) 0.783s 60 1.00x
💻 Local Nitro 0.542s (+5.9% 🔺) 1.005s (~) 0.462s 60 2.43x
💻 Local Express 0.568s (+10.1% 🔺) 1.005s (~) 0.436s 60 2.55x
💻 Local Next.js (Turbopack) 0.625s (-2.7%) 1.022s (~) 0.397s 59 2.80x
🐘 Postgres Express ⚠️ missing - - - -
🐘 Postgres Next.js (Turbopack) ⚠️ missing - - - -

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 1.424s (+26.5% 🔺) 2.874s (+9.3% 🔺) 1.450s 21 1.00x
▲ Vercel Nitro 1.543s (+40.8% 🔺) 3.067s (+27.7% 🔺) 1.524s 21 1.08x
▲ Vercel Next.js (Turbopack) 2.472s (+26.3% 🔺) 3.899s (+16.1% 🔺) 1.427s 16 1.74x

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

workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 0.345s (-1.4%) 1.029s (+2.3%) 0.684s 88 1.00x
💻 Local Nitro 2.463s (-2.5%) 3.009s (~) 0.546s 30 7.13x
💻 Local Express 2.526s (~) 3.009s (-1.1%) 0.483s 30 7.32x
💻 Local Next.js (Turbopack) 2.589s (-5.7% 🟢) 3.009s (-4.4%) 0.419s 30 7.50x
🐘 Postgres Express ⚠️ missing - - - -
🐘 Postgres Next.js (Turbopack) ⚠️ missing - - - -

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 1.473s (+11.3% 🔺) 3.149s (+26.5% 🔺) 1.676s 29 1.00x
▲ Vercel Express 1.547s (+8.5% 🔺) 3.129s (+2.5%) 1.582s 29 1.05x
▲ Vercel Next.js (Turbopack) 3.228s (+24.6% 🔺) 5.198s (+28.0% 🔺) 1.970s 18 2.19x

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

workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 0.595s (+3.8%) 1.161s (+7.7% 🔺) 0.566s 104 1.00x
💻 Local Nitro 5.357s (-8.5% 🟢) 8.224s (-5.4% 🟢) 2.867s 15 9.00x
💻 Local Express 5.844s (-2.3%) 8.226s (-3.1%) 2.382s 15 9.82x
💻 Local Next.js (Turbopack) 6.005s (-6.6% 🟢) 8.737s (-5.5% 🟢) 2.732s 14 10.09x
🐘 Postgres Express ⚠️ missing - - - -
🐘 Postgres Next.js (Turbopack) ⚠️ missing - - - -

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 2.030s (+21.8% 🔺) 4.097s (+15.7% 🔺) 2.068s 30 1.00x
▲ Vercel Express 2.329s (+45.1% 🔺) 4.275s (+17.5% 🔺) 1.947s 29 1.15x
▲ Vercel Next.js (Turbopack) 4.715s (+12.2% 🔺) 6.577s (+14.8% 🔺) 1.862s 19 2.32x

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

Stream Benchmarks (includes TTFB metrics)
workflow with stream

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Next.js (Turbopack) 1.154s (+1.3%) 1.964s (~) 0.013s (+26.2% 🔺) 2.020s (~) 0.867s 10 1.00x
🐘 Postgres Nitro 1.158s (~) 1.998s (~) 0.001s (~) 2.010s (~) 0.852s 10 1.00x
💻 Local Express 1.161s (~) 2.005s (~) 0.012s (+7.9% 🔺) 2.020s (~) 0.858s 10 1.01x
💻 Local Nitro 1.167s (+1.0%) 2.005s (~) 0.011s (+8.6% 🔺) 2.019s (~) 0.852s 10 1.01x
🐘 Postgres Express ⚠️ missing - - - - -
🐘 Postgres Next.js (Turbopack) ⚠️ missing - - - - -

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 1.966s (+4.0%) 3.307s (+2.1%) 1.973s (+20.4% 🔺) 5.799s (+8.5% 🔺) 3.833s 10 1.00x
▲ Vercel Nitro 1.971s (+3.4%) 3.784s (+27.8% 🔺) 1.406s (-23.5% 🟢) 5.713s (+9.4% 🔺) 3.742s 10 1.00x
▲ Vercel Next.js (Turbopack) 3.951s (+24.0% 🔺) 4.457s (+27.4% 🔺) 1.968s (+35.2% 🔺) 7.932s (+23.2% 🔺) 3.980s 10 2.01x

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

stream pipeline with 5 transform steps (1MB)

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 1.543s (-5.3% 🟢) 2.002s (~) 0.005s (+2.6%) 2.026s (~) 0.483s 30 1.00x
💻 Local Nitro 1.568s (-0.9%) 2.011s (-1.4%) 0.013s (-6.0% 🟢) 2.027s (-1.6%) 0.459s 30 1.02x
💻 Local Express 1.574s (~) 2.010s (~) 0.012s (~) 2.026s (~) 0.451s 30 1.02x
💻 Local Next.js (Turbopack) 1.632s (+1.9%) 1.970s (~) 0.013s (+6.0% 🔺) 2.027s (~) 0.395s 30 1.06x
🐘 Postgres Express ⚠️ missing - - - - -
🐘 Postgres Next.js (Turbopack) ⚠️ missing - - - - -

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 5.629s (-1.1%) 7.388s (+12.0% 🔺) 0.390s (+67.3% 🔺) 8.396s (+13.3% 🔺) 2.767s 8 1.00x
▲ Vercel Express 5.704s (+5.1% 🔺) 7.200s (+2.1%) 0.474s (+112.4% 🔺) 8.154s (+4.8%) 2.450s 8 1.01x
▲ Vercel Next.js (Turbopack) 10.089s (+6.1% 🔺) 11.884s (+12.8% 🔺) 0.337s (+54.0% 🔺) 13.056s (+13.0% 🔺) 2.967s 5 1.79x

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

10 parallel streams (1MB each)

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 0.767s (-9.5% 🟢) 1.043s (-7.1% 🟢) 0.000s (-7.0% 🟢) 1.062s (-7.0% 🟢) 0.295s 57 1.00x
💻 Local Express 1.277s (-5.8% 🟢) 1.919s (~) 0.000s (-12.5% 🟢) 1.921s (~) 0.644s 32 1.66x
💻 Local Next.js (Turbopack) 1.366s (-2.5%) 1.977s (~) 0.000s (-36.8% 🟢) 2.017s (~) 0.651s 30 1.78x
💻 Local Nitro 1.385s (+2.6%) 1.981s (+1.7%) 0.000s (+66.7% 🔺) 1.983s (+1.6%) 0.599s 31 1.81x
🐘 Postgres Express ⚠️ missing - - - - -
🐘 Postgres Next.js (Turbopack) ⚠️ missing - - - - -

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 3.130s (-40.1% 🟢) 4.560s (-31.1% 🟢) 0.000s (-75.0% 🟢) 5.029s (-29.6% 🟢) 1.899s 12 1.00x
▲ Vercel Nitro 3.247s (-21.0% 🟢) 4.723s (-10.1% 🟢) 0.000s (+Infinity% 🔺) 5.230s (-8.6% 🟢) 1.983s 12 1.04x
▲ Vercel Next.js (Turbopack) 5.163s (+14.8% 🔺) 6.226s (+15.2% 🔺) 0.000s (+Infinity% 🔺) 7.123s (+15.4% 🔺) 1.960s 9 1.65x

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

fan-out fan-in 10 streams (1MB each)

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 1.565s (-12.2% 🟢) 2.172s (-10.1% 🟢) 0.000s (+Infinity% 🔺) 2.189s (-10.0% 🟢) 0.624s 28 1.00x
💻 Local Nitro 3.167s (-2.3%) 3.778s (-3.2%) 0.002s (+150.0% 🔺) 3.782s (-3.2%) 0.615s 16 2.02x
💻 Local Express 3.396s (+2.2%) 3.902s (~) 0.001s (+466.7% 🔺) 3.906s (~) 0.511s 16 2.17x
💻 Local Next.js (Turbopack) 3.481s (+0.6%) 3.988s (~) 0.001s (-47.1% 🟢) 4.028s (~) 0.548s 15 2.22x
🐘 Postgres Express ⚠️ missing - - - - -
🐘 Postgres Next.js (Turbopack) ⚠️ missing - - - - -

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 4.834s (+11.1% 🔺) 6.492s (+23.6% 🔺) 0.000s (+Infinity% 🔺) 6.995s (+23.5% 🔺) 2.161s 9 1.00x
▲ Vercel Express 4.969s (+10.7% 🔺) 6.472s (+13.6% 🔺) 0.000s (NaN%) 6.934s (+12.5% 🔺) 1.965s 9 1.03x
▲ Vercel Next.js (Turbopack) 8.308s (+12.9% 🔺) 9.300s (+19.1% 🔺) 0.000s (NaN%) 10.754s (+20.2% 🔺) 2.446s 6 1.72x

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

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World 🥇 Fastest Framework Wins
💻 Local Nitro 15/21
🐘 Postgres Nitro 21/21
▲ Vercel Nitro 13/21
Fastest World by Framework

Winner determined by most benchmark wins

Framework 🥇 Fastest World Wins
Express 💻 Local 15/21
Next.js (Turbopack) 💻 Local 19/21
Nitro 🐘 Postgres 19/21
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
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Redis + BullMQ: Community world (local development)
  • 🌐 Cloudflare: Community world (local development)
  • 🌐 MySQL: Community world (local development)
  • 🌐 Azure: Community world (local development)
  • 🌐 NATS JetStream: Community world (local development)
  • 🌐 Upstash: Community world (local development)
  • 🌐 Platformatic: Community world (local development)

📋 View full workflow run

@github-actions

github-actions Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

Passed Failed Skipped Total
✅ ▲ Vercel Production 1442 0 230 1672
✅ 💻 Local Development 1453 0 219 1672
✅ 📦 Local Production 1605 0 219 1824
✅ 🐘 Local Postgres 1605 0 219 1824
✅ 🪟 Windows 152 0 0 152
✅ 📋 Other 887 0 177 1064
Total 7144 0 1064 8208

Details by Category

✅ ▲ Vercel Production
App Passed Failed Skipped
✅ astro 125 0 27
✅ example 125 0 27
✅ express 125 0 27
✅ fastify 125 0 27
✅ hono 125 0 27
✅ nextjs-turbopack 149 0 3
✅ nextjs-webpack 149 0 3
✅ nitro 125 0 27
✅ nuxt 125 0 27
✅ sveltekit 144 0 8
✅ vite 125 0 27
✅ 💻 Local Development
App Passed Failed Skipped
✅ astro-stable 127 0 25
✅ express-stable 127 0 25
✅ fastify-stable 127 0 25
✅ hono-stable 127 0 25
✅ nextjs-turbopack-canary 133 0 19
✅ nextjs-webpack-canary 133 0 19
✅ nextjs-webpack-stable 152 0 0
✅ nitro-stable 127 0 25
✅ nuxt-stable 127 0 25
✅ sveltekit-stable 146 0 6
✅ vite-stable 127 0 25
✅ 📦 Local Production
App Passed Failed Skipped
✅ astro-stable 127 0 25
✅ express-stable 127 0 25
✅ fastify-stable 127 0 25
✅ hono-stable 127 0 25
✅ nextjs-turbopack-canary 133 0 19
✅ nextjs-turbopack-stable 152 0 0
✅ nextjs-webpack-canary 133 0 19
✅ nextjs-webpack-stable 152 0 0
✅ nitro-stable 127 0 25
✅ nuxt-stable 127 0 25
✅ sveltekit-stable 146 0 6
✅ vite-stable 127 0 25
✅ 🐘 Local Postgres
App Passed Failed Skipped
✅ astro-stable 127 0 25
✅ express-stable 127 0 25
✅ fastify-stable 127 0 25
✅ hono-stable 127 0 25
✅ nextjs-turbopack-canary 133 0 19
✅ nextjs-turbopack-stable 152 0 0
✅ nextjs-webpack-canary 133 0 19
✅ nextjs-webpack-stable 152 0 0
✅ nitro-stable 127 0 25
✅ nuxt-stable 127 0 25
✅ sveltekit-stable 146 0 6
✅ vite-stable 127 0 25
✅ 🪟 Windows
App Passed Failed Skipped
✅ nextjs-turbopack 152 0 0
✅ 📋 Other
App Passed Failed Skipped
✅ e2e-local-dev-nest-stable 127 0 25
✅ e2e-local-dev-tanstack-start- 127 0 25
✅ e2e-local-postgres-nest-stable 127 0 25
✅ e2e-local-postgres-tanstack-start- 127 0 25
✅ e2e-local-prod-nest-stable 127 0 25
✅ e2e-local-prod-tanstack-start- 127 0 25
✅ e2e-vercel-prod-tanstack-start 125 0 27

📋 View full workflow run


Some E2E test jobs failed:

  • Vercel Prod: success
  • Local Dev: failure
  • Local Prod: success
  • Local Postgres: success
  • Windows: success

Check the workflow run for details.

FNV-1a (64-bit) over a seed string → 8-byte writerId. Callers pass a
value stable across deterministic replays (a seeded STABLE_ULID), so
the writerId is replay-stable without consuming the VM's seeded RNG
(which would shift the sequence observed by user code).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
recoverStreamTail reconstructs, after an unclean segment failure, which of a
writer's in-flight frames the backend persisted, and replays only the rest:

- Scans the persisted tail window [max(priorIndices)+1, tailIndex], matching
  the writer's OWN frames by the framed-v2 marker (concurrent writers share a
  stream, so a server index isn't attributable to one writer).
- Handles the reserve-ahead race (tailIndex can point at a reserved-but-
  unpersisted chunk) with read-with-backoff 10/100/1000ms; a tail chunk still
  missing after the window is a real write failure, surfaced not skipped.
- Replays frames with seq > max-persisted-seq on a fresh writeStream, bounded
  by maxAttempts (re-scans between attempts since a replay may partially
  persist).

StreamSegmentWriter.recover now receives the prior clean segment's indices as
the scan anchor. Both fully unit-tested; not yet wired into the writer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rewrite WorkflowServerWritableStream from unconditional 10ms timer
batching to lazy sink selection: when the world exposes
streams.writeStream and the writer has a writerId (framed-v2), frames
flow through StreamSegmentWriter (one long-lived streaming request per
~10s segment, clean 200 drops the buffer, recoverStreamTail replays
unconfirmed frames on unclean failure). Otherwise the existing
per-batch writeMulti path is used, extracted verbatim into
createBatchSink.

No behavior change yet: no caller passes a writerId, so all writers
stay on the batch path until getWritable is wired for framed-v2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread packages/core/src/serialization.ts Outdated
VaguelySerious and others added 2 commits July 2, 2026 13:49
Replace the streaming-PUT write path with an ack'd WebSocket write
channel, and wire framed-v2 emission end to end.

Transport: `Streamer.connectWrite` opens a channel on the world-vercel
v3 `/ws` stream route (undici WebSocket so the upgrade carries the same
auth headers as every HTTP call). Each chunk is one binary message,
persisted + published on arrival and acked back `{index, chunkIndex}`
in order. The streaming-PUT `writeStream` is removed: the platform
buffers streaming request bodies whole, so it could never deliver
incremental visibility (probed empirically; the WS path delivers
~75ms p50 chunk-to-ack on deployed infra).

Writer: `StreamSocketWriter` replaces the segment writer — the local
buffer is evicted per ack instead of per request, bounded by an
in-flight window; connections recycle proactively under the server's
bound; an unclean close resends everything unacked on a fresh channel,
with consecutive + lifetime reconnect budgets. The tail-scan recovery
module is removed: read-side framed-v2 dedupe (writerId+seq) absorbs
the persisted-but-unacked resend overlap, which acks make tiny.

Flip: `getWritable` derives a replay-stable writerId and emits
framed-v2 when `framedStreamMarkersEnabled(own version)` — a per-run
decision every reader can reproduce. `getReadable` derives the same
answer from the run's `executionContext.workflowCoreVersion` via a
lazy thenable (fetched only when the first chunk arrives). Framing is
per-stream: forwarded writables carry `framing` in their descriptor
(and through the workflow VM round-trip), and a forwarded writer mints
its own writerId. `WORKFLOW_EXPERIMENTAL_STREAM_MARKERS=1`
force-enables for tests/e2e ahead of the version cutoff, so e2e can
assert engagement instead of silently exercising the legacy path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread packages/core/src/serialization/stream-socket-writer.ts Outdated
vercel Bot and others added 8 commits July 6, 2026 18:23
…ice for one failed attempt, double-counting `consecutiveReconnects`/`totalReconnects` and scheduling two reconnect timers.

This commit fixes the issue reported at packages/core/src/serialization/stream-socket-writer.ts:248

## Bug

`StreamSocketWriter.ensureChannel()` opens a channel via `deps.connect(...)` and, on failure, reports the failure through `onChannelClose`. The epoch guard in `onChannelClose` is meant to make it idempotent: it bumps `this.epoch` and ignores any subsequent call whose captured epoch no longer matches. But the `catch` block passed the *live* `this.epoch` instead of the epoch captured for this attempt, which defeats the guard on the connect-failure path.

### Why it double-fires for a failed WS upgrade

The transport is `createStreamer().connectWrite` in `packages/world-vercel/src/streamer.ts`. It registers listeners in this order:

1.  A **persistent** `close` listener → `emitClose(...)` → `handlers.onClose(event)` (registered before awaiting the connect promise).
2.  Inside `await new Promise(...)`, a `{ once: true }` `close` listener that **rejects** the connect promise.

When the upgrade fails, a `close` event fires before `open`. Listeners run in registration order:

1.  The persistent listener fires **synchronously** → `handlers.onClose` → `this.onChannelClose(epoch, event)`. Captured `epoch === this.epoch`, so it proceeds: sets `channel = null`, increments `this.epoch`, increments `consecutiveReconnects`/`totalReconnects`, schedules a reconnect timer.
2.  The `once` listener then rejects the promise (settled as a microtask), so `connectWrite` throws and `ensureChannel`'s `catch` runs → `this.onChannelClose(this.epoch, ...)`. Because `this.epoch` was just bumped in step 1, the passed value **again equals the current** `this.epoch`, so it proceeds a **second** time — double-incrementing the counters and scheduling a second timer.

### Impact

A single failed connect counts as two reconnects. The writer gives up after ~3 real consecutive failures instead of `maxConsecutiveReconnects` (5), and the lifetime `maxTotalReconnects` budget is effectively halved. Two overlapping reconnect timers are also scheduled.

The existing unit tests don't catch this because the test harness's `connect` rejects **without** invoking `handlers.onClose`, so it only exercises the single (catch-only) path. The real transport fires both.

## Fix

In `ensureChannel()`, hoist `const epoch = ++this.epoch;` (and the `this.sentInEpoch = 0` reset) above the `try` so the attempt's epoch is captured once, and pass that **captured** `epoch` into the `catch`'s `onChannelClose` call instead of `this.epoch`.

Now for a failed WS upgrade:

*   Step 1 (`handlers.onClose`) runs with the captured epoch, matches, and bumps `this.epoch`.
*   Step 2 (`catch`) runs with the same captured epoch, which no longer matches the bumped `this.epoch`, so the guard makes it a stale no-op.

Other paths are unchanged: the connect-reject-only path (no `onClose`) still fires once via the catch with a matching epoch; the `ensureReady`-throws path fires once (epoch bumped, nothing else touched `this.epoch`); the successful-then-closed path fires once via `onClose`. Moving the epoch bump before the `ensureReady` await is behavior-preserving — `abort()`/`teardownChannel()` during `ensureReady` sets `fatalError` (and doesn't bump epoch when there's no channel), and the post-connect `if (epoch !== this.epoch || this.fatalError)` check still short-circuits.


Co-authored-by: Vercel <vercel[bot]@users.noreply.github.com>
Co-authored-by: VaguelySerious <mittgfu@gmail.com>
…pty stream, so `X-Stream-Done` can race run creation in turbo optimistic start (run-not-found / lost close marker).

This commit fixes the issue reported at packages/core/src/serialization.ts:1094

## Bug

Commit `3b08d48` replaced `createSegmentSink` with `createSocketSink` (`packages/core/src/serialization.ts`), but the empty-stream ordering gap remains. `close()` was:

```js
close: async () => {
  await socketWriter.close();
  await world.streams.close(runId, name);
},
```

The run-ready barrier (`ensureRunReady`) is passed to `StreamSocketWriter` as `ensureReady`, and it is only awaited inside `ensureChannel()`:

```js
private async ensureChannel(): Promise<void> {
  if (this.channel || this.connecting || this.fatalError) return;
  this.connecting = true;
  try {
    if (this.deps.ensureReady) {
      this.ensureReadyPromise ??= this.deps.ensureReady();
      await this.ensureReadyPromise;
    }
    ...
```

`ensureChannel()` is only reached via `pump()`, and `pump()` opens a channel only when `this.buffer.length > 0`. For an **empty stream** (writable opened and closed with no writes), `StreamSocketWriter.close()` sees an empty buffer and returns without ever opening a channel:

```js
async close(): Promise<void> {
  this.throwIfUnusable();
  if (this.buffer.length > 0) {
    await new Promise<void>((resolve) => {
      this.drainWaiter = resolve;
      this.pump();
    });
    this.throwIfUnusable();
  }
  this.teardownChannel();
  this.closedDone = true;
}
```

So `ensureReady`/`ensureRunReady` is never awaited, and the sink immediately fires `world.streams.close(runId, name)` (the `X-Stream-Done` PUT).

### Trigger / failure mode

Turbo optimistic start (`runReadyBarrier` provided) + an empty stream that is closed with no writes. The `X-Stream-Done` PUT reaches the World before `run_started` is durable → run-not-found error / lost close marker. The `WorkflowServerWritableStream` doc even asserts the empty-stream close "orders after the run-ready barrier" — which the socket sink did not satisfy.

The sibling `createBatchSink.close()` already handles exactly this case:

```js
await flush();
// A close with an empty buffer skips flush()'s write (and its barrier),
// but can itself be the first write to a brand-new stream — gate it too.
await ensureRunReady();
await world.streams.close(runId, name);
```

## Fix

Await `ensureRunReady()` before `world.streams.close(runId, name)` in `createSocketSink.close()`, mirroring the batch sink. Because `ensureRunReady`/`ensureReadyPromise` is memoized after the first await, this is a no-op when a channel already opened, and correctly orders the empty-stream close after run creation.

## Reachability note

The socket sink is selected only when the world supports `connectWrite` and a `writerId` is present. This is code being wired up, so the fix prevents a latent race once the socket sink is enabled in production.


Co-authored-by: Vercel <vercel[bot]@users.noreply.github.com>
Co-authored-by: VaguelySerious <mittgfu@gmail.com>
…esulting on-wire frame length (chunk.length + FRAME_MARKER_SIZE) exceeds MAX_FRAME_SIZE, so the unframer rejects them — breaking the documented "any chunk the framer accepts can always be decoded by the unframer" invariant.

This commit fixes the issue reported at packages/core/src/serialization.ts:591

## Bug

In `packages/core/src/serialization.ts`, `getByteFramingStream(writerId?)` validates user chunks at write time (now around line 591) with a single `chunk.length > MAX_FRAME_SIZE` check that ignores the framed-v2 marker:

```ts
if (chunk.length > MAX_FRAME_SIZE) { controller.error(...); return; }
if (writerId) {
  controller.enqueue(buildFramedV2Frame(chunk, { writerId, seq: seq++ }));
  return;
}
```

For **framed-v2** (`writerId` present), `buildFramedV2Frame` (in `serialization/frame-marker.ts`) emits a frame whose 4-byte length prefix declares `bodyLength = FRAME_MARKER_SIZE + inner.length`, where `FRAME_MARKER_SIZE = WRITER_ID_SIZE (8) + 8 = 16` bytes:

```ts
const bodyLength = FRAME_MARKER_SIZE + inner.length;
view.setUint32(0, bodyLength, false);
```

The reader `getByteUnframingStream` rejects any frame whose declared length exceeds the cap:

```ts
if (frameLength > MAX_FRAME_SIZE) { controller.error("...exceeds maximum..."); return; }
```

### Concrete trigger

A framed-v2 user chunk with `chunk.length` in `(MAX_FRAME_SIZE - FRAME_MARKER_SIZE, MAX_FRAME_SIZE]` — e.g. exactly `100_000_000` bytes — passes the writer's `chunk.length > MAX_FRAME_SIZE` check (100,000,000 is not `> 100,000,000`), so the framer enqueues a frame with declared length `100_000_016`. The reader then evaluates `frameLength (100_000_016) > MAX_FRAME_SIZE (100_000_000)` as true and **errors the stream** with "exceeds maximum", failing an otherwise valid framed-v2 stream. This violates the `MAX_FRAME_SIZE` docstring guarantee that "any chunk the framer accepts can always be decoded by the unframer."

## Fix

Tighten the write-time bound to mirror the reader's effective limit, subtracting the marker only when a `writerId` is present:

```ts
const maxChunkSize = writerId
  ? MAX_FRAME_SIZE - FRAME_MARKER_SIZE
  : MAX_FRAME_SIZE;
if (chunk.length > maxChunkSize) { controller.error(... maxChunkSize ...); return; }
```

`FRAME_MARKER_SIZE` is already imported into `serialization.ts` from `./serialization/frame-marker.js` (line 55), so no new import is needed. For framed-v1 (no `writerId`) `maxChunkSize` stays `MAX_FRAME_SIZE`, preserving existing behavior. Now any framed-v2 chunk the framer accepts produces a frame length ≤ `MAX_FRAME_SIZE`, and oversized chunks fail loudly at the producer where the error is actionable.

## Verification note

The code was refactored (commit 3b08d48) but the bug is unchanged; only the line moved (~534 → ~582/591). `buildFramedV2Frame` still sets the length prefix to `FRAME_MARKER_SIZE + inner.length`, and the unframer still enforces `frameLength > MAX_FRAME_SIZE`. Patch re-applied at the new location.


Co-authored-by: Vercel <vercel[bot]@users.noreply.github.com>
Co-authored-by: VaguelySerious <mittgfu@gmail.com>
All stream PUT operations (write, writeMulti, close) now target the v3
stream endpoint, which publishes each chunk's availability as its write
lands instead of deferring publishes to the end of the request body.
The v2 write path is removed.

Sets WORKFLOW_SERVER_URL_OVERRIDE to the backend branch preview so e2e
exercises the v3 endpoints — temporary, reverted before merge. Tests
that mock the server derive their origin from getHttpUrl() so they hold
under any override; tests asserting unset-override defaults skip while
an inline override is active.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
# Conflicts:
#	packages/core/src/serialization.ts
#	packages/world-vercel/src/streamer.ts
Extends the WORKFLOW_* env-override pattern to the socket writer's
config: recycle interval, in-flight window (frames/bytes), and
reconnect budgets resolve through envNumber so a dedicated e2e
deployment can dial them down and exercise rotation, backpressure,
and reconnect paths quickly. Explicit constructor config still wins;
the recycle floor guards against connection-churn misconfiguration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rumentedFetch

Leftover from the abandoned single-request streaming write design (the
platform buffers streaming request bodies whole, so long-lived writes
go over the WS write channel instead). No caller passes a stream body;
writes send fully-buffered bodies.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… writeMulti

Core now has a single world-agnostic write path: the buffered sink
flushes through writeMulti, passing { retransmitSafe: true } when frames
carry framed-v2 per-writer markers (readers can deduplicate resends).
How a batch is delivered becomes the world's concern — world-vercel
routes retransmit-safe batches over the long-lived acknowledged
WebSocket channel (one writer per stream, drained by close() before the
done marker) and everything else over the batched PUT. The
Streamer.connectWrite channel API is removed from @workflow/world;
StreamSocketWriter and its channel types move into world-vercel. Other
worlds are unaffected: the new writeMulti option is optional and
ignored where unsupported.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread packages/world-vercel/src/streamer.ts
vercel Bot and others added 6 commits July 6, 2026 21:19
…eam no longer tears down world-vercel's per-stream StreamSocketWriter, leaking the writer and leaving its WebSocket reconnecting/resending abandoned data.

This commit fixes the issue reported at packages/world-vercel/src/streamer.ts:369

## What's wrong

Commit `2cd6fcd` moved the WebSocket write transport out of core and behind `writeMulti`. Before the refactor, core's `createSocketSink.abort(reason)` called `socketWriter.abort(reason)`, which tore down the `StreamSocketWriter`: it clears the recycle timer, closes the WebSocket, abandons unacked frames, sets `fatalError`, and rejects in-flight waiters.

After the refactor, the `StreamSocketWriter` lives in world-vercel's `createStreamer` in a per-stream `socketWriters` map, created by `writeMulti(..., { retransmitSafe: true })` and removed only in two places:

- `close()` — drains the writer and deletes the entry, then sends `X-Stream-Done`.
- `writeMulti`'s `catch` — deletes the entry when `writer.write()` throws a *fatal* error.

Core's replacement `createBatchSink.abort` (packages/core/src/serialization.ts) became **purely local** — it clears the buffer and rejects flush waiters but calls nothing on `world.streams`. The `Streamer` interface (packages/world/src/interfaces.ts) had **no stream-abort method**, so there was no way for an aborted `WritableStream` to signal world-vercel to tear the writer down.

## Failure mode / trigger

`WorkflowServerWritableStream` is an exported class whose underlying sink implements `abort(reason)` (which delegates to `createBatchSink.abort`). Aborting a **framed-v2** instance — e.g. `writer.abort(reason)` (a standard, exercised `WritableStream` API; see `writable-stream.test.ts` "abort behavior") — takes the abort path. With the retransmit-safe socket writer in the map, that path never reaches the writer, so:

1. The `StreamSocketWriter` entry stays in `socketWriters` forever (unbounded growth in a long-lived process).
2. If it still holds unacked frames, it keeps the WebSocket open and reconnects/resends them — delivering data for a stream that was aborted, and burning reconnect budget.
3. It is never drained or torn down.

This is a genuine regression of the deliberate teardown that existed pre-refactor, on a public/exported contract.

### Reachability caveat (recorded honestly)

In the *current* production wiring, framed-v2 writables are driven by `flushablePipe`, whose error path releases the writer lock without calling the sink's `abort()`. So the abort *contract* is what is broken today; the primary in-repo trigger is a direct `.abort()` on the writable. (I also filed a separate note: `flushablePipe`'s error path itself never tears the socket writer down — a related, pre-existing leak on *source* errors that this change does not address.)

## The fix

- **packages/world/src/interfaces.ts**: add an optional `abort?(runId, name, reason?): Promise<void>` to `Streamer.streams`, documenting that retransmit-safe worlds must stop reconnecting/resending here. Optional so worlds with no per-stream write state are unaffected (feature-detected).
- **packages/world-vercel/src/streamer.ts**: implement `abort` to look up the stream's `StreamSocketWriter`, delete it from the map, and call `writer.abort(reason)` (teardown + abandon unacked frames).
- **packages/core/src/serialization.ts**: `createBatchSink.abort` now returns `world.streams.abort?.(runId, name, reason)` (optional-chained, so it no-ops for worlds without the method), and `StreamSink.abort`'s return type widens to `void | Promise<void>`. The `WorkflowServerWritableStream` sink's `abort` handler now awaits the sink abort so world-level teardown completes before the writable settles.

This restores the pre-refactor guarantee: aborting a stream tears down the world's transport-level write state instead of leaking it.

Co-authored-by: Vercel <vercel[bot]@users.noreply.github.com>
Co-authored-by: VaguelySerious <mittgfu@gmail.com>
- Fix a drain deadlock: close() hung forever when the recycle rotation
  completed on the same ack that emptied the buffer (pump() tore the
  channel down and returned before resolving the drain waiter).
- Treat a 1009 close (message too big) as fatal with a chunk-size error
  instead of resending the oversized frame until the reconnect budget dies.
- Lower the default in-flight window to 64 frames so it stays under the
  server's per-connection backlog cap (100).
- Inject W3C trace context on the WS upgrade inside a client span, like
  every other request to the backend (covered in trace-propagation.test.ts).
- Register an ack barrier with the platform's waitUntil so an invocation
  is not suspended while admitted frames still await durability
  confirmation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Byte streams (step-returned and workflow-argument ReadableStreams) now
  emit framed-v2 with a per-writer marker when the target run's capability
  allows, giving them the same read-side dedupe and retransmit-safe write
  path as getWritable() streams. Threaded as framedStreamMarkers through
  the reducers and dehydrate entry points, driven by getRunCapabilities.
- Remove the WORKFLOW_EXPERIMENTAL_STREAM_MARKERS escape hatch; the
  capability cutoff (5.0.0-beta.26, at/below the tree version) turns the
  framed-v2 + retransmit-safe path on across unit and e2e lanes in CI.
  TODO(release): bump the cutoff to the first beta that ships framed-v2.
- Rewrite comments that still described the abandoned single-request
  streaming design (tail-scan recovery) — markers exist so readers can
  deduplicate frames a retransmitting write transport resent.
- Split the changeset into core+world and world-vercel entries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A workflow-VM getWritable() handle was a bare {name}: a step (or external
client) reviving it saw no framing field and wrote framed-v1, while
Run.getReadable derives framed-v2 from the run's SDK version and strips a
16-byte marker that was never written — corrupting every frame (caught by
world-testing's hooks test hanging on an unparseable event stream).

The run's stream framing is now computed host-side at VM setup
(framedStreamMarkersEnabled over this SDK's version — the VM cannot import
the capability table without pulling the serialization module into the
workflow bundle) and exposed as the WORKFLOW_DEFAULT_STREAM_FRAMING global;
getWritable tags its handles with it, so the existing reducer/reviver
framing round-trip applies to VM-minted handles like every other writable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
# Conflicts:
#	packages/core/src/workflow/create-hook.ts
Sign up for free to 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.

1 participant