Skip to content

[world-testing] Fix Unit Tests flake after adding event-limit test - #3055

Merged
VaguelySerious merged 2 commits into
mainfrom
peter/fix-windows-event-limit-test
Jul 24, 2026
Merged

[world-testing] Fix Unit Tests flake after adding event-limit test#3055
VaguelySerious merged 2 commits into
mainfrom
peter/fix-windows-event-limit-test

Conversation

@VaguelySerious

Copy link
Copy Markdown
Member

Summary

Windows Unit Tests failed on main's changeset-release PR (#3028) at the new event-limit.test.ts test (added in #2986):

FAIL test/event-limit.test.ts > fails a runaway run at the server-supplied event limit (turbo=1)
AssertionError: expected 'CORRUPTED_EVENT_LOG' to be 'MAX_EVENTS_EXCEEDED'

CORRUPTED_EVENT_LOG means the run hit ReplayDivergenceError 3+ times (REPLAY_DIVERGENCE_MAX_RETRIES) before the max-events guard could cleanly fail it.

Root cause

Every startServer() call in world-testing spawns a fresh Local World server process against the shared, untagged.workflow-data directory (no tag, no isolated dataDir). The Local World's start() defaults recoverActiveRuns to true and, when untagged, its recovery sweep lists every untagged run in that shared directory — not just the ones the current process created (see the comment in packages/world-local/src/index.ts around the isUntagged filter).

So a server spawned by one test file, starting up while another test file's run is still mid-flight, re-enqueues that foreign run into its own in-process queue and dispatches it against the same on-disk event log — racing the run's actual owner. That produces spurious ReplayDivergenceErrors under enough concurrency (the full suite, as CI runs it), which is why it reproduced on the Windows job's own head-to-head run but not on a solo re-run of the file.

Confirmed locally: running the full world-testing suite concurrently (6x in parallel, simulating CI-level file-level concurrency) reliably produced idempotency timeouts and inline-batches-debug failures before the fix, and passed cleanly 6/6 after.

Fix

Give each spawned server its own WORKFLOW_LOCAL_DATA_DIR (a unique temp dir), removing the shared state entirely. Cleaned up in onTestFinished.

Testing

  • pnpm build && npx vitest run in packages/world-testing: 3/3 files, 15/15 tests pass.
  • 6x concurrent full-suite runs (90 tests total): all pass, no flakes (previously reproduced the same failure family as CI).

@vercel

vercelBot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Jul 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 23b996c

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

This PR includes changesets to release 1 package
NameType
@workflow/world-testingPatch

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 Jul 22, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production145502391694
✅ 💻 Local Development162102271848
✅ 📦 Local Production162102271848
✅ 🐘 Local Postgres162102271848
✅ 🪟 Windows15400154
✅ 📋 Other102002121232
✅ vercel-multi-region270027
Total7519011328651
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro126028
✅ example126028
✅ express126028
✅ fastify126028
✅ hono126028
✅ nextjs-turbopack15103
✅ nextjs-webpack15103
✅ nitro126028
✅ nuxt126028
✅ sveltekit14509
✅ vite126028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack15400

✅ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128026
✅ e2e-local-dev-tanstack-start-128026
✅ e2e-local-postgres-nest-stable128026
✅ e2e-local-postgres-tanstack-start-128026
✅ e2e-local-prod-nest-stable128026
✅ e2e-local-prod-tanstack-start-128026
✅ e2e-vercel-prod-nest126028
✅ e2e-vercel-prod-tanstack-start126028

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@github-actions

github-actionsBot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 8f27731 · Fri, 24 Jul 2026 19:41:39 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep312 (-64%) 💚1287 🔴 (+11%)1362 🔴 (+15%) 🔻1455 🔴 (-5.0%)30
TTFSstream1347 (+428%) 🔻1384 🔴 (+22%) 🔻1392 🔴 (+20%) 🔻1447 🔴 (+14%)30
TTFShook + stream1453 (+12%)1601 🔴 (+8.1%)1613 🔴 (+4.9%)1653 🔴 (-5.7%)30
STSO1020 steps (1-20)142 (-22%) 💚320 🔴 (+6.7%)416 🔴 (+12%)704 🔴 (+61%) 🔻19
STSO1020 steps (101-120)180 (-13%)298 🔴 (-13%)422 🔴 (-3.9%)461 🔴 (±0%)19
STSO1020 steps (1001-1020)468 (-6.0%)550 🔴 (-11%)614 🔴 (-3.6%)660 🔴 (+0.6%)19
WO1020 steps396664 (-9.6%)396664 (-9.6%)396664 (-9.6%)396664 (-9.6%)1
SLstream latency76 (-37%) 💚155 🔴 (-17%) 💚167 🔴 (-25%) 💚330 🔴 (-44%) 💚30
SOstream overhead (text)105 (-28%) 💚163 (-44%) 💚236 (-44%) 💚471 (-31%) 💚30
SOstream overhead (structured)102 (-36%) 💚152 (-49%) 💚185 (-56%) 💚654 (-63%) 💚30
📜 Previous results (2)

23b996c

Fri, 24 Jul 2026 18:56:45 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1247 (+45%) 🔻1335 🔴 (+15%) 🔻1371 🔴 (+16%) 🔻1856 🔴 (+21%) 🔻30
TTFSstream1244 (+388%) 🔻1361 🔴 (+19%) 🔻1368 🔴 (+18%) 🔻1473 🔴 (+17%) 🔻30
TTFShook + stream672 (-48%) 💚1602 🔴 (+8.2%)1620 🔴 (+5.3%)1705 🔴 (-2.7%)30
STSO1020 steps (1-20)169 (-6.6%)271 🔴 (-9.7%)342 🔴 (-7.8%)452 🔴 (+3.4%)19
STSO1020 steps (101-120)186 (-10%)229 🔴 (-33%) 💚241 🔴 (-45%) 💚277 🔴 (-40%) 💚19
STSO1020 steps (1001-1020)462 (-7.2%)594 🔴 (-3.4%)640 🔴 (±0%)841 🔴 (+28%) 🔻19
WO1020 steps394233 (-10%)394233 (-10%)394233 (-10%)394233 (-10%)1
SLstream latency85 (-29%) 💚151 🔴 (-19%) 💚170 🔴 (-23%) 💚282 🔴 (-53%) 💚30

ae73058

Wed, 22 Jul 2026 16:50:59 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep138 (+13%)320 🔴 (+33%) 🔻388 🔴 (+19%) 🔻2210 🔴 (+413%) 🔻30
TTFSstream114 (-26%) 💚269 🔴 (-21%) 💚343 🔴 (-7.0%)589 (+27%) 🔻30
TTFShook + stream267 (-13%)486 🔴 (+5.2%)2250 🔴 (+343%) 🔻2297 🔴 (+270%) 🔻30
STSO1020 steps (1-20)155 (-18%) 💚286 🔴 (-2.7%)379 🔴 (+12%)454 🔴 (+30%) 🔻19
STSO1020 steps (101-120)202 (-9.4%)293 🔴 (-8.7%)400 🔴 (-17%) 💚437 🔴 (-13%)19
STSO1020 steps (1001-1020)643 (+6.3%)886 🔴 (+13%)2185 🔴 (+151%) 🔻2634 🔴 (+138%) 🔻19
WO1020 steps485216 (-8.2%)485216 (-8.2%)485216 (-8.2%)485216 (-8.2%)1
SLstream latency89 (-1.1%)157 🔴 (+4.7%)259 🔴 (+43%) 🔻448 🔴 (+96%) 🔻30
ℹ️ Metric definitions & methodology

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000 · STSO (1-20) 20/30/60 · STSO (101-120) 30/45/90 · STSO (1001-1020) 40/60/120

All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

@VaguelySerious
VaguelySerious marked this pull request as ready for review July 22, 2026 16:40
@VaguelySerious
VaguelySerious requested review from a team and ijjk as code ownersJuly 22, 2026 16:40
@VaguelySerious
VaguelySerious enabled auto-merge (squash) July 22, 2026 16:46

@karthikscale3karthikscale3 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.

One cleanup/lifecycle issue found.

Comment threadpackages/world-testing/src/util.mts Outdated
});
onTestFinished(() => {
proc.kill();
void rm(dataDir, { recursive: true, force: true });

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.

proc.kill() only requests termination; it does not wait for the child to exit. On Windows the Local World process may still have files in dataDir open when this rm() runs, so it can reject with EPERM/EBUSY. Because the promise is discarded, that also becomes an unhandled rejection (and the temp directory may leak), potentially replacing the original flake with a teardown flake. Could this hook be made async, await the child's exit/close after killing it, and then await rm() (ideally with maxRetries/retryDelay for Windows)?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Good catch — fixed in 23b996c, and the race was live on POSIX too, not just theoretical on Windows.

Teardown now waits for the child's exit before removing the dir (bounded at 5s so a child that refuses to die can't hang teardown), retries the removal (maxRetries: 10, retryDelay: 100) for Windows handle release, and awaits it with a .catch that warns instead of throwing — a leaked temp dir shouldn't fail an otherwise passing test.

Measured before/after on macOS, full world-testing suite:

leaked temp dirsteardown warningsunhandled rejections
void rm() (previous)3 (one per test file)0
awaited exit + retries000

The leaked dirs contained files the server wrote afterrm() ran (events/*.json, .locks/steps/*), which is the same race you identified — POSIX unlink just doesn't error on it, so it silently recreated the tree instead of throwing EPERM.

6x concurrent full-suite runs (90 tests) with the fix: all pass, 0 leaked dirs, 0 warnings, 0 unhandled rejections.

@TooTallNateTooTallNate 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.

Reviewed and reproduced the validation. Approving.

The diagnosis is the valuable part and it holds up. The untagged recovery sweep re-enqueuing every untagged run in the shared .workflow-data — including another live server's mid-flight runs — is exactly the mechanism that would produce spurious ReplayDivergenceErrors escalating to CORRUPTED_EVENT_LOG after the 3-retry divergence budget, and it squares with the earlier recovery-scoping fix (which excluded tagged runs from untagged sweeps but by design left untagged-vs-untagged sharing in place). The concurrency-dependence also explains the flake's signature perfectly: reproducible under CI's file-level parallelism, clean on solo re-runs.

The implementation gets the fiddly parts right:

  • Isolation via pid + UUID temp dirs, with the env spread ordered so an explicit opts.env override still wins.
  • The second commit's teardown ordering is the Windows-critical piece: kill() only requests termination, so awaiting the actual exit (bounded at 5s with an unref'd timer so a wedged child can't hang teardown) before rm is what prevents the EPERM/EBUSY class — and the retried, warn-don't-fail rm covers the handles-linger-past-exit tail.
  • I checked for hidden shared-state dependencies: the only world construction lives in the spawned server.mts (which inherits the env var); every test file interacts purely through the HTTP fetcher, so nothing reads the data dir out-of-band and no test expects state continuity across servers.

Verified locally: 15/15 on a single run, then 3× fully concurrent suite runs all green (a scaled-down version of the PR's 6× validation), and zero workflow-world-testing-* dirs leaked in the temp dir afterward — cleanup demonstrably works. CI is 100/100 including both Windows lanes, which are precisely the lanes this fixes.

One non-blocking thought: the harness is fixed, but the underlying product behavior remains — any two untagged local-world processes sharing a project directory (say, a dev server restarting while its predecessor is mid-drain, or two dev servers by mistake) can still recovery-sweep each other's live runs into a divergence race. If that's not already tracked, it may deserve an issue — e.g. a per-process lease on the data dir, or recovery limited to runs whose last event is older than some liveness horizon. The test flake was the canary; the harness isolation removes the canary rather than the hazard.

@VaguelySerious
VaguelySerious merged commit 6670e08 into mainJul 24, 2026
175 of 177 checks passed
@VaguelySerious
VaguelySerious deleted the peter/fix-windows-event-limit-test branch July 24, 2026 22:46
@github-actionsgithub-actionsBot mentioned this pull request Jul 24, 2026
github-actionsBot added a commit that referenced this pull request Jul 24, 2026
)
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
@github-actions

Copy link
Copy Markdown
Contributor

Backport PR opened against stable: #3104. Merge conflicts were resolved by AI — please review carefully. (backport job run)

VaguelySerious added a commit that referenced this pull request Jul 25, 2026
) (#3104)
Co-authored-by: Peter Wielander <mittgfu@gmail.com>
VaguelySerious added a commit that referenced this pull request Jul 27, 2026
Resolves conflicts with the precondition-guard backport (#3079) and the
world-testing per-server data-dir isolation fix (#3055):
- packages/core/src/runtime.ts: keep both error imports
(MaxEventsExceededError + PreconditionFailedError); both are used.
- packages/world-testing/src/util.mts: keep both spawn env entries;
WORKFLOW_LOCAL_DATA_DIR first so opts.env can still override it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pranaygp added a commit that referenced this pull request Jul 28, 2026
…ry-2
* origin/main: (292 commits)
feat(core): seal forwarded stream writes to the owner's public key (#3098)
feat(core): seal hook payloads to the target run's public key (#3096)
[e2e] Rebuild the event-log corruption repro around step-count divergence (#3147)
feat: decrypt sealed payloads in the dashboard and CLI (#3146)
Prewarm only appended replay payloads (#3131)
feat: publish each run's X25519 public key on the run entity (#3095)
feat(core): route sealed envelopes through the serialization layer (#3094)
docs: redirect retired migration-guides URLs to comparisons (#3127)
feat(core): add `encp` sealed-box encryption primitive (#3093)
chore(core): clarify runtime comments (#3111)
Remove obsolete world factory aliases (#3112)
feat(core): deterministic sandbox hardening (#3045)
Remove retired v1 step route plumbing (#3061)
[core] Don't count racing invocations' duplicate step_started events toward the maxRetries ceiling (#3069)
[world-testing] Isolate each spawned test server's data directory (#3055)
fix: upgrade postcss to >=8.5.18 to address GHSA-r28c-9q8g-f849 (#3102)
[next] Respect .gitignore in dev watcher to avoid EMFILE on large monorepos (#3085)
[ci] Backport only stability fixes to `stable`, default to claude-opus-5 (#3092)
perf(core): immediate leading-edge dispatch for idle streams (flush window default 0) (#3088)
Optimize `processImportSpecifier` by computing `shouldFollowImportsFromFile` once per file (#3052)
...
# Conflicts:
#	docs/components/geistdocs/desktop-menu.tsx
#	docs/components/geistdocs/mobile-menu.tsx
#	docs/content/docs/v5/cookbook/advanced/child-workflows.mdx
#	docs/content/docs/v5/cookbook/advanced/upgrading-workflows.mdx
#	docs/content/docs/v5/cookbook/agent-patterns/agent-cancellation.mdx
#	docs/content/docs/v5/cookbook/agent-patterns/durable-agent.mdx
#	docs/content/docs/v5/cookbook/agent-patterns/human-in-the-loop.mdx
#	docs/content/docs/v5/cookbook/common-patterns/batching.mdx
#	docs/content/docs/v5/cookbook/common-patterns/idempotency.mdx
#	docs/content/docs/v5/cookbook/common-patterns/rate-limiting.mdx
#	docs/content/docs/v5/cookbook/common-patterns/saga.mdx
#	docs/content/docs/v5/cookbook/common-patterns/scheduling.mdx
#	docs/content/docs/v5/cookbook/common-patterns/sequential-and-parallel.mdx
#	docs/content/docs/v5/cookbook/common-patterns/timeouts.mdx
#	docs/content/docs/v5/cookbook/common-patterns/webhooks.mdx
#	docs/content/docs/v5/cookbook/common-patterns/workflow-composition.mdx
#	docs/content/docs/v5/cookbook/index.mdx
#	docs/content/docs/v5/cookbook/integrations/ai-sdk.mdx
#	docs/content/docs/v5/cookbook/integrations/chat-sdk.mdx
#	docs/content/docs/v5/cookbook/integrations/sandbox.mdx
#	docs/next.config.ts
#	docs/proxy.ts
#	docs/scripts/lint.ts
#	pnpm-lock.yaml
#	pnpm-workspace.yaml
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.

3 participants

@VaguelySerious@TooTallNate@karthikscale3