Skip to content

[world] Guard hook_received against a concurrent run termination#2987

Open
VaguelySerious wants to merge 2 commits into
mainfrom
peter/hook-received-terminal-run-guard
Open

[world] Guard hook_received against a concurrent run termination#2987
VaguelySerious wants to merge 2 commits into
mainfrom
peter/hook-received-terminal-run-guard

Conversation

@VaguelySerious

Copy link
Copy Markdown
Member

Summary

  • hook_received had no branch in the terminal-run guard (it doesn't transition the run or create an entity), so a run reaching a terminal state (run_completed/run_failed/run_cancelled) concurrently with an in-flight hook_received could still get its event appended after the run was already done.
  • world-local: re-read run status immediately before the event publish, under the same in-process hookLocks lock already held for hook_received, and throw RunExpiredError if terminal — mirroring the existing last-instant hook_disposed-vs-hook_received ordering check right above it.
  • world-postgres: hook_received previously fell through to the generic, unguarded events INSERT. It now runs inside its own drizzle.transaction, taking a FOR UPDATE lock on the run row before inserting — the same linearization technique step_started already uses against a concurrent terminal step event, adapted here to guard against a concurrent terminal run transition (whose own conditional UPDATE takes the same row lock).

Test plan

  • world-local: added a "linearization guard" test that writes a terminal run status directly to disk (bypassing the run-completion hook cleanup) so the hook still exists, then confirms hook_received is rejected with RunExpiredError. Verified it fails without the fix.
  • world-postgres: added the equivalent test, updating the runs row directly via drizzle (bypassing the run-completion cleanup) so the hook row still exists, then confirms hook_received is rejected with RunExpiredError. Verified it fails without the fix.
  • Also added a sequential "hook already cleaned up" case for both worlds, documenting that the natural (non-racy) path surfaces HookNotFoundError instead, since terminal-run transitions already delete all hooks for the run.
  • Full pnpm vitest run suites pass for both @workflow/world-local (216 tests) and @workflow/world-postgres (119 tests, via testcontainers).
  • pnpm typecheck and pnpm build pass for both packages.
  • Changeset added (patch bump for both packages).

🤖 Generated with Claude Code

Add a terminal-run status re-check for hook_received at the same
linearization point as its event write, closing the race where the
run reaches a terminal state (run_completed/failed/cancelled) between
hook_received's earlier validation reads and its write.

- world-local: re-read the run status immediately before the event
  publish, under the same in-process hookLocks lock already held for
  hook_received, and throw RunExpiredError if terminal.
- world-postgres: wrap the status check and event insert in a single
  transaction, taking a FOR UPDATE lock on the run row so the insert
  is linearized against a concurrent terminal-transition UPDATE.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 17, 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 17, 2026 6:15pm
example-nextjs-workflow-webpack Ready Ready Preview, Comment Jul 17, 2026 6:15pm
example-workflow Ready Ready Preview, Comment Jul 17, 2026 6:15pm
workbench-astro-workflow Ready Ready Preview, Comment Jul 17, 2026 6:15pm
workbench-express-workflow Ready Ready Preview, Comment Jul 17, 2026 6:15pm
workbench-fastify-workflow Ready Ready Preview, Comment Jul 17, 2026 6:15pm
workbench-hono-workflow Ready Ready Preview, Comment Jul 17, 2026 6:15pm
workbench-nitro-workflow Ready Ready Preview, Comment Jul 17, 2026 6:15pm
workbench-nuxt-workflow Ready Ready Preview, Comment Jul 17, 2026 6:15pm
workbench-sveltekit-workflow Ready Ready Preview, Comment Jul 17, 2026 6:15pm
workbench-tanstack-start-workflow Ready Ready Preview, Comment Jul 17, 2026 6:15pm
workbench-vite-workflow Ready Ready Preview, Comment Jul 17, 2026 6:15pm
workflow-docs Ready Ready Preview, Comment, Open in v0 Jul 17, 2026 6:15pm
workflow-swc-playground Ready Ready Preview, Comment Jul 17, 2026 6:15pm
workflow-tarballs Ready Ready Preview, Comment Jul 17, 2026 6:15pm
workflow-web Ready Ready Preview, Comment Jul 17, 2026 6:15pm

@changeset-bot

changeset-bot Bot commented Jul 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 5a4616d

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

This PR includes changesets to release 18 packages
Name Type
@workflow/world-local Patch
@workflow/world-postgres Patch
@workflow/builders Patch
@workflow/cli Patch
@workflow/core Patch
@workflow/vitest Patch
@workflow/web Patch
@workflow/world-testing Patch
@workflow/astro Patch
@workflow/nest Patch
@workflow/next Patch
@workflow/nitro Patch
@workflow/nuxt Patch
@workflow/rollup Patch
@workflow/sveltekit Patch
@workflow/vite Patch
workflow Patch
@workflow/web-shared 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

@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 5a4616d · Fri, 17 Jul 2026 18:29:14 GMT · run logs

Backend: vercel · app: nextjs-turbopack

Metric Scenario Avg (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS stream 1341 (+5.9%) 1696 🔴 1774 🔴 2156 🔴 30
TTFS hook + stream 1655 (+25%) 1941 🔴 2040 🔴 2164 🔴 30
STSO 1020 steps (1-20) 327 (+9.3%) 374 🔴 478 🔴 775 🔴 19
STSO 1020 steps (101-120) 308 (±0%) 333 🔴 375 🔴 439 🔴 19
STSO 1020 steps (1001-1020) 718 (+15%) 755 🔴 886 🔴 1000 🔴 19
WO stream 1341 (+5.9%) 1696 1774 2156 30
WO hook + stream 1655 (+25%) 1941 2040 2164 30
SL stream 3167 (+217%) 5905 🔴 6003 🔴 6137 🔴 30
SL hook + stream 4476 (+129%) 5802 🔴 5868 🔴 5984 🔴 30
📜 Previous results (1)

3bbe1f8

Fri, 17 Jul 2026 17:40:02 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Avg (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS stream 1440 (+14%) 1766 🔴 1783 🔴 1815 🔴 30
TTFS hook + stream 1560 (+18%) 1987 🔴 2028 🔴 2152 🔴 30
STSO 1020 steps (1-20) 292 (-2.3%) 309 🔴 500 🔴 525 🔴 19
STSO 1020 steps (101-120) 357 (+16%) 367 🔴 407 🔴 1295 🔴 19
STSO 1020 steps (1001-1020) 755 (+21%) 781 🔴 889 🔴 1029 🔴 19
WO stream 1440 (+14%) 1766 1783 1815 30
WO hook + stream 1560 (+18%) 1987 2028 2152 30
SL stream 3680 (+268%) 5949 🔴 6013 🔴 6104 🔴 30
SL hook + stream 4018 (+106%) 5819 🔴 5918 🔴 6091 🔴 30

Avg deltas compare against the most recent benchmark run on main at the time of this run.

Metrics — TTFS: time to first step body execution · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (time outside step bodies, client start → last step body exit) · SL: stream latency (first chunk write → visible to the reader)

Scenarios — stream: one step that streams chunks back to the client; no hooks, so the run stays in turbo mode · hook + stream: registers a hook before the same streaming step, which exits turbo mode · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges

🟢/🔴 mark percentiles within/above target. Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · STSO (1-20) 20/30/60 · STSO (101-120) 30/45/90 · STSO (1001-1020) 40/60/120

TTFS/WO compare client vs deployment clocks and SL compares the step runner’s clock vs the client’s (NTP-synced in CI). WO ends at the last step body exit, the closest observable proxy for the final step-completion request.

@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

Passed Failed Skipped Total
✅ ▲ Vercel Production 1453 0 230 1683
✅ 💻 Local Development 1617 0 219 1836
✅ 📦 Local Production 1617 0 219 1836
✅ 🐘 Local Postgres 1617 0 219 1836
✅ 🪟 Windows 153 0 0 153
✅ 📋 Other 894 0 177 1071
✅ vercel-multi-region 27 0 0 27
Total 7378 0 1064 8442

Details by Category

✅ ▲ Vercel Production
App Passed Failed Skipped
✅ astro 126 0 27
✅ example 126 0 27
✅ express 126 0 27
✅ fastify 126 0 27
✅ hono 126 0 27
✅ nextjs-turbopack 150 0 3
✅ nextjs-webpack 150 0 3
✅ nitro 126 0 27
✅ nuxt 126 0 27
✅ sveltekit 145 0 8
✅ vite 126 0 27
✅ 💻 Local Development
App Passed Failed Skipped
✅ astro-stable 128 0 25
✅ express-stable 128 0 25
✅ fastify-stable 128 0 25
✅ hono-stable 128 0 25
✅ nextjs-turbopack-canary 134 0 19
✅ nextjs-turbopack-stable 153 0 0
✅ nextjs-webpack-canary 134 0 19
✅ nextjs-webpack-stable 153 0 0
✅ nitro-stable 128 0 25
✅ nuxt-stable 128 0 25
✅ sveltekit-stable 147 0 6
✅ vite-stable 128 0 25
✅ 📦 Local Production
App Passed Failed Skipped
✅ astro-stable 128 0 25
✅ express-stable 128 0 25
✅ fastify-stable 128 0 25
✅ hono-stable 128 0 25
✅ nextjs-turbopack-canary 134 0 19
✅ nextjs-turbopack-stable 153 0 0
✅ nextjs-webpack-canary 134 0 19
✅ nextjs-webpack-stable 153 0 0
✅ nitro-stable 128 0 25
✅ nuxt-stable 128 0 25
✅ sveltekit-stable 147 0 6
✅ vite-stable 128 0 25
✅ 🐘 Local Postgres
App Passed Failed Skipped
✅ astro-stable 128 0 25
✅ express-stable 128 0 25
✅ fastify-stable 128 0 25
✅ hono-stable 128 0 25
✅ nextjs-turbopack-canary 134 0 19
✅ nextjs-turbopack-stable 153 0 0
✅ nextjs-webpack-canary 134 0 19
✅ nextjs-webpack-stable 153 0 0
✅ nitro-stable 128 0 25
✅ nuxt-stable 128 0 25
✅ sveltekit-stable 147 0 6
✅ vite-stable 128 0 25
✅ 🪟 Windows
App Passed Failed Skipped
✅ nextjs-turbopack 153 0 0
✅ 📋 Other
App Passed Failed Skipped
✅ e2e-local-dev-nest-stable 128 0 25
✅ e2e-local-dev-tanstack-start- 128 0 25
✅ e2e-local-postgres-nest-stable 128 0 25
✅ e2e-local-postgres-tanstack-start- 128 0 25
✅ e2e-local-prod-nest-stable 128 0 25
✅ e2e-local-prod-tanstack-start- 128 0 25
✅ e2e-vercel-prod-tanstack-start 126 0 27
✅ vercel-multi-region
App Passed Failed Skipped
✅ nextjs-turbopack 27 0 0

📋 View full workflow run

@VaguelySerious
VaguelySerious marked this pull request as ready for review July 17, 2026 17:23
@VaguelySerious
VaguelySerious requested review from a team and ijjk as code owners July 17, 2026 17:23
// the terminal-run guard every other event type gets earlier in
// this function (hook_received has no branch there because it
// doesn't transition the run or create an entity).
if (data.eventType === 'hook_received') {

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.

[P1] Serialize this check with the event write

This re-read does not eliminate the race because terminal lifecycle writes are serialized with withRunFileLock, while this check and the later writeExclusive are outside that lock. hook_received can read running, a concurrent run_completed can acquire the run lock and write completed, and then hook_received can still publish its event. Please put the status re-read and event publication in the same withRunFileLock(effectiveRunId, ...) critical section used by terminal transitions. The new test directly makes the run terminal before calling events.create, so it verifies stale-status detection but cannot catch this check-to-write interleaving; a deterministic concurrent test should cover both lock orderings.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch — fixed in 5a4616d. The re-read is now inside withRunFileLock(effectiveRunId, ...), the same critical section run_completed/run_failed/run_cancelled use for their own terminal-transition write (via writeRunUnderLifecycleLock), and the writeExclusive publish happens inside that same lock. So hook_received either waits behind an in-flight terminal transition and observes the committed status, or completes its own check-then-publish before a transition's critical section can begin — no interleaving where both proceed.

Also added a deterministic test that manually occupies the run-file lock and asserts hook_received cannot settle until the hold is released (proving it actually queues on the shared lock, not just that it happens to see a pre-set status), plus a genuine concurrent Promise.allSettled race test that fuzzes both lock orderings.

Address review feedback: re-reading run status outside the shared
withRunFileLock used by run_completed/run_failed/run_cancelled did not
actually close the race, since a concurrent terminal transition could
still acquire that lock and commit between the unlocked re-read and
the event write. Move the check and the writeExclusive publish inside
the same withRunFileLock(effectiveRunId, ...) critical section so the
two are mutually exclusive with a run's terminal transition.

Add a deterministic test that manually occupies the run-file lock and
asserts hook_received cannot settle until the hold releases, proving
it actually queues on the shared lock rather than merely observing a
status set before it started. Also add a genuine concurrent race test
(Promise.allSettled) fuzzing both lock orderings.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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.

2 participants