Skip to content

fix(world-vercel,world-local): hold process-wide state on globalThis - #3728

Merged
pranaygp merged 14 commits into
mainfrom
pgp/bundling-regressions
Aug 21, 2026
Merged

fix(world-vercel,world-local): hold process-wide state on globalThis#3728
pranaygp merged 14 commits into
mainfrom
pgp/bundling-regressions

Conversation

@pranaygp

@pranaygp pranaygp commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Generalizes #3699 from one symbol to the class of bug behind it.

What broke

serverExternalPackages was the only thing making @workflow/world-vercel a process singleton. #3493 removed it — for a real cold-start win — and every module-scope const/let in the package quietly became one copy per bundler layer.

A bundler keys module identity on (resource, layer). Next.js alone builds instrument, app-route, ssr and edge layers, so a bundled module is compiled and evaluated once per layer inside one process; a package left in serverExternalPackages is emitted as a runtime require() and Node's module cache dedupes it to one instance.

What makes this bite rather than merely waste memory: core caches the World object on globalThis, while the module state that World closes over stays layer-local. Anything a World reaches at request time therefore has to be process-wide too.

The visible casualty was the WS events transport. Core has two independent world caches, each calling createWorld():

  • getWorldHandlers() → queue consumer → openWsChannel → registers in that copy's transports Map
  • getWorld() → events write path → resolveWsTransport → reads that copy's Map

Before #3493 both resolved into the same module instance. After, the socket opens in one Map and every lookup reads the other, empty one — a deterministic miss, silently demoting every event to HTTP for the life of the process.

Reproduction

Standalone app staged from tarballs (real node_modules — a pnpm workspace link defeats serverExternalPackages, so the workbench cannot show the counterfactual), Next 16.2.11, WORKFLOW_TARGET_WORLD=vercel, instrumentation.ts awaiting getWorld(). Probes counted live module instances:

build bundler world-vercel instances createWorld() ran in
bundled (main today) turbopack 3 2 different copies
bundled (main today) webpack 3 2 different copies
external (pre-#3493) turbopack 1 1 copy, both times

Turbopack emits four byte-identical (8702 B) copies of ws-transport.js under four module ids — one pulled by instrumentation.js, one by every app/**/route.js, one under chunks/ssr/, one under edge/chunks/.

Two things this establishes beyond the reported symptom:

  • Instrumentation is not required. With no warm-up at all there are still 2 live instances (route + ssr). An app that starts a run from a Server Component and consumes it in a route handler already has two worlds.
  • @workflow/world-local has the same exposure, and always has — it is a static import in world.ts too. @workflow/world-postgres and other custom worlds are safe: they load through getRuntimeRequire() with webpackIgnore/turbopackIgnore, so Node dedupes them.

Per #3493's own table, webpack + pnpm apps never got externalization even before it — so a revert would be both a regression in cold start and an incomplete fix.

The fix

globalSingleton(name, shapeVersion, create) in @workflow/utils — the primitive @workflow/core already hand-rolls for its World cache — parks state on globalThis under a Symbol.for() key. shapeVersion is part of the key, so two releases of a package sharing one process don't meet on the same key with different expectations of the object.

Fourteen files converted, every mutable module-scope binding in both worlds:

  • world-vercelws-transport (the channel registry + its two log latches), http-client (the undici keep-alive pools and the events dispatcher recycler: per-copy pools mean a warm-up warms a pool no route dispatches on, and each layer pays its own TLS handshake), runs (long-poll support negative cache), create-run-id (the monotonic ULID factory — two copies can mint the same run ID in one millisecond), telemetry, queue.
  • world-localstorage/runs-storage (runFileLocks: a duplicated mutex stops mutually excluding), storage/hook-index, storage/helpers and streamer (monotonic ULID factories for evnt_/chnk_ ids), fs, init, telemetry, build-target-mismatch.

State that is deliberately per-copy keeps a // per-copy-ok: <why> annotation — the OTel diagnostic (which reports what this copy sees) and queue.ts's AsyncLocalStorage (both ends live in one closure).

Regression cover

Three layers, because a test for the WebSocket symptom would not stop the next module-scope let:

  1. packages/utils/src/global-singleton.test.ts — the primitive's semantics: identity, factory-runs-once, cross-holder mutation, shapeVersion isolation.
  2. packages/world-vercel/src/ws-transport-module-copies.test.ts — the shipped bug in miniature. Imports the module twice in one process and asserts a transport registered by one copy is found by the other. Verified to fail on a plain module-scope Map (2 of its 3 assertions) and pass with the fix; the third assertion pins that the two imports really are distinct instances, so the test cannot go vacuous.
  3. scripts/lint/module-scope-state.mjs — the class. A TypeScript-AST rule that flags any module-scope binding these packages mutate at runtime, with globalSingleton(...) and // per-copy-ok: <reason> as the only two escapes (a bare per-copy-ok with no reason does not count). Surfaced as a vitest file in each package so turbo test gates it, with seven fixture self-tests pinning both directions — four sources that must be flagged, three that must stay clean — so the rule cannot rot into a no-op.

Verification

  • pnpm build — 28/28 packages.
  • @workflow/world-vercel 559 tests, @workflow/world-local 557, @workflow/utils 117 — all pass; typecheck and Biome clean on the touched packages.
  • The lint rule reports zero findings on both packages (it scored 90 before the conversion).

Deliberately not in this PR

  • Merging getWorld / getWorldHandlers — the comment in packages/core/src/runtime/world.ts already contemplates it. Two caches each calling createWorld() is what turned "duplicated module" into a deterministic cross-copy miss rather than a coin flip. Worth doing, separately.
  • An e2e guard. e2e-vercel-ws-transport passes whether events go over WS or fall back to HTTP, and no workbench instrumentation.ts warms the world — which is why CI never saw this. A transport assertion plus a warm-up would give that lane the failing shape.
  • A miss breadcrumb in resolveWsTransport. The silence here cost several investigations.

Codifying the rule (added after review discussion)

The valid worry with the fix above is that it trades one invariant ("module scope is process-wide") for a discipline someone has to keep. There is no in-bundle way to restore the invariant — module identity is (resource, layer), each Next server layer is its own compilation, and the server gets no shared runtime chunk (runtimeChunk is client-only in 16.2.11). The only two namespaces that cross layers at runtime are Node's require cache (be external) and globalThis. So the rule is the mechanism, and it has to be written down where someone will meet it:

  • docs/content/worlds/{v4,v5}/building-a-world.mdx — a Process-wide state section for custom-world authors: the two loading modes (runtime require(), deduped by Node — vs bundled, one copy per layer), why which one you get is not yours to assume, and a nudge to prefer World-instance state over a global.
  • packages/world/README.md — the same constraint stated on the contract package.
  • CLAUDE.md — for the next contributor working in these packages.
  • packages/core/src/runtime/world.ts — at the two static imports, which is precisely where a bundled world diverges from a required one.
  • The rule's own error message, which now explains the mechanism and the two escapes instead of just naming a helper.

The guard is consolidated so a future world is covered without anyone remembering: @workflow/utils owns the rule and its fixture self-tests, and sweeps every published packages/world-* discovered at runtime (world-local, world-postgres, world-testing, world-vercel; private world-sim is out of scope). Each world keeps a one-assertion mirror so the signal still arrives when you run just that package's tests.

@workflow/world-postgres scans clean today and stays pinned — it is deduped only because getRuntimeRequire() loads it, which is a property of how it is loaded, not how it is written, and is exactly what flipped for world-vercel in #3493.

Follow-ups, tracked

This PR contains the bleeding; the structural work is filed so it survives the merge:

#3665 Done in this PR — see the section above. #3666's one-line change is adopted here, so this closes on merge.
#3729 Partly overtaken: the sweep now covers core and the other bundled packages. What remains is moving World-scoped state onto the World instance, so the ws registry, undici pools and long-poll cache can't be duplicated at all, leaving globalThis for the genuinely process-wide remainder. Turns the rule into a property of the design.
#3730 CI can't catch a recurrence. e2e-vercel-ws-transport passes on silent HTTP fallback, no workbench warms the world in instrumentation.ts, and resolveWsTransport misses without a breadcrumb.
#3731 Unrelated, noticed in passing: @workflow/world has 13 test files and no test script, so none run in CI — and one fails on main (a SPEC_VERSION_CURRENT bump left a stale assertion nobody saw).

Docs Preview

Page v4 v5
Building a World → Process-wide state /worlds/building-a-world#process-wide-state /v5/worlds/building-a-world#process-wide-state

Also in this PR: #3666, adopted (Fixes #3665)

workflowEntrypoint's lazy handler init now calls getWorld() instead of getWorldHandlers() — one line, originally written by @MintedKenny in #3666, which implements #3665 and could not run CI as a fork PR.

It belongs here because it is the other half of the same bug. getWorldHandlers() owns a second, build-time-safe cache, so the runtime route built a second World in the same process — and for a bundled world package those two Worlds are built by two different module copies, which is the mechanism behind the WS regression above. get-world-lazy.ts:25-45 makes it deterministic rather than a race: the events write path resolves only from WorldCache and never reads StubbedWorldCache.

The two changes are complementary, not redundant:

  • fix(core): reuse runtime World for route handlers #3666's line makes the common case structurally correct: one World, so one module copy.
  • The rest of this PR removes the dependence on that case holding. getWorldHandlers() stays public API, and @workflow/vitest, the CLI, packages/web and setWorld() all create Worlds by design — so create-run-id's monotonic factory, which must not fork its sequence, still needs to be process-wide.

It also pays for itself independently of bundling: world-postgres/src/index.ts:60-64 eagerly constructs a pg.Pool (default max: 10) in createWorld(), and queue.ts:91 eagerly constructs a whole nested world-local World. Two createWorld() calls per process means self-hosted users have been getting 20 connections where they configured 10, plus two nested Worlds, on every instance.

Kept from the original: the regression test asserting the factory runs exactly once, and the api-reference wording (re-applied over #3704's list punctuation). The public getWorldHandlers() and its build-time cache are untouched.

Not taken — worth a reviewer's opinion: renaming the workflow.route.get_world_handlers span, which now measures a getWorld() call. It is a distinct span from the per-request workflow.route.get_world at the top of the flow route, so reusing that name would collide with it both in traces and in runtime-trace-mode.test.ts. A comment records why the name outlived the call.

Every bundled package, not just the worlds

@workflow/core is bundled into the host server build the same way the worlds are, and always has been — the original repro measured three live copies in every arm, including the pre-#3493 external one.

One instance is not reachable, and the reason is worth recording. Layers cannot share a module, and core cannot be made external because core is workflow code: runtime/start.ts:253 and nine methods in runtime/run.ts are 'use step' functions, so it has to go through the SWC loader. An external package never does. The Next integration already encodes that rule generally — it strips workflow-bearing packages back out of serverExternalPackages and warns (packages/next/src/index.ts:217-221).

So the duplication stays and the hazard is removed instead, everywhere it can occur:

Package Was Now
@workflow/core 22 0
@workflow/world 1 0
@workflow/ai 2 0
@workflow/nest 3 0
the four world packages 0 0, and now actually scanned

Mostly warn-once latches and lazy caches. Three had teeth:

  • step-single-flight.ts — a per-copy map is not single-flight. Two invocations arriving through different layers would each believe they were alone in the process and both run the step body, silently degrading in-process dedup to the cross-process residual its own doc scopes out to the ownership lease.
  • @workflow/nest — bootstrap config in a module-level let plus two static class fields. Configure one copy, read another, and the controller is unconfigured for the life of the process.
  • hardened.ts's useStep closure brand — a function marked by one copy was invisible to another.

Five sites are deliberately per-copy and now say why: state keyed on objects that never cross copies (the barrier safety-net WeakSet, the QuickJS pending-byte WeakMap), the synchronously-scoped guest-code sink, and the OTel diagnostic that reports what this copy sees.

Scope of the sweep. Every published packages/world-* (discovered at runtime) plus core, world, ai, nest, named in BUNDLED_RUNTIME_PACKAGES. Out: build-time code (next, builders, sveltekit), the CLI, the o11y UI (web, web-shared), the test runner (vitest), and private packages — all single-module-graph, where the hazard cannot occur. AGENTS.md records the criterion, because "does this run in the host's server bundle" is a judgement rather than something to infer from a directory name.

Relationship to #3699

@shalabhc's #3699 fixes the ws registry and the two log latches with the same technique, arrived at independently. This PR subsumes it and extends the treatment to the remaining twelve files plus the lint rule. Happy to land #3699 first and rebase this on top if that's easier to review — the overlapping hunk is ws-transport.ts's state block.

Both packages are bundled into the host application's server build, and a
bundler keys module identity on (resource, layer) — Next.js alone builds
`instrument`, app-route, `ssr` and `edge` layers, so one process holds one
copy of each of these modules per layer. Every module-scope `const`/`let` in
them was therefore per-copy state wearing the costume of a process singleton.

#3493 made `@workflow/world-vercel` bundled rather than
external and the events WebSocket transport regressed to HTTP for exactly
this reason: the queue consumer registered its channel in the `instrument`
copy's `Map` and the write path looked it up in the route copy's empty one. A
deterministic miss, for the life of the process. `@workflow/world-local` had
the same exposure all along — including `runFileLocks`, where a duplicated
mutex simply stops mutually excluding.

Add `globalSingleton()` to `@workflow/utils` (the primitive `@workflow/core`
already hand-rolls for its World cache) and route every mutable module-scope
binding in both worlds through it.

Regression cover, in three layers:

- `global-singleton.test.ts` pins the primitive's semantics.
- `ws-transport-module-copies.test.ts` imports the module twice in one
  process and asserts a transport registered by one copy is found by the
  other — it fails on a plain module-scope `Map`, which is the shipped bug.
- `scripts/lint/module-scope-state.mjs` fails the class: an AST rule banning
  mutable module-scope state in these packages, with `// per-copy-ok: <why>`
  as the deliberate escape. Wired into both packages' `vitest run src`, with
  fixture self-tests so it cannot rot into a no-op.
@pranaygp
pranaygp requested a review from a team as a code owner August 21, 2026 21:36
Copilot AI lite review requested due to automatic review settings August 21, 2026 21:36
@changeset-bot

changeset-bot Bot commented Aug 21, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: afa0302

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

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

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@vercel

vercel Bot commented Aug 21, 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, v0 Aug 21, 2026 11:36pm
example-nextjs-workflow-webpack Ready Ready Preview, v0 Aug 21, 2026 11:36pm
example-workflow Ready Ready Preview, v0 Aug 21, 2026 11:36pm
workbench-astro-workflow Ready Ready Preview, v0 Aug 21, 2026 11:36pm
workbench-express-workflow Ready Ready Preview, v0 Aug 21, 2026 11:36pm
workbench-fastify-workflow Ready Ready Preview, v0 Aug 21, 2026 11:36pm
workbench-hono-workflow Ready Ready Preview, v0 Aug 21, 2026 11:36pm
workbench-nestjs-workflow Ready Ready Preview, v0 Aug 21, 2026 11:36pm
workbench-nitro-workflow Ready Ready Preview, v0 Aug 21, 2026 11:36pm
workbench-nuxt-workflow Ready Ready Preview, v0 Aug 21, 2026 11:36pm
workbench-python-workflow Ready Ready Preview, v0 Aug 21, 2026 11:36pm
workbench-sveltekit-workflow Ready Ready Preview, v0 Aug 21, 2026 11:36pm
workbench-tanstack-start-workflow Ready Ready Preview, v0 Aug 21, 2026 11:36pm
workbench-vite-workflow Ready Ready Preview, v0 Aug 21, 2026 11:36pm
workflow-docs Ready Ready Preview, v0 Aug 21, 2026 11:36pm
workflow-swc-playground Ready Ready Preview, v0 Aug 21, 2026 11:36pm
workflow-tarballs Ready Ready Preview, v0 Aug 21, 2026 11:36pm
workflow-web Ready Ready Preview, v0 Aug 21, 2026 11:36pm

…s world

It is deduped today only because `getRuntimeRequire()` loads it — a property
of how it is loaded, not how it is written, and exactly what changed for
world-vercel in #3493. The package is already clean; this keeps it that way.
@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Sim World

Simulated world deterministic testing for races. Traces

🟠 world-sim scenario book — 1 fail of 41 total

fence=per-spec

scenario outcome events virt replay violations
smoke-no-steps completed 3 0ms ok 0
smoke-one-step completed 6 0ms ok 0
hook-at-step-started completed 12 0ms ok 0
hook-at-step-completed completed 12 0ms ok 0
hook-at-hook-created completed 12 0ms ok 0
deadline-hook-wins completed 7 1.0h ok 0
deadline-expires completed 7 1.0h ok 0
long-sleep completed 11 30.0d ok 0
hook-never-arrives stalled 3 0ms skipped 0
step-retries-twice completed 10 2.0s ok 0
parallel-steps completed 9 0ms ok 0
hook-on-execution-state completed 12 0ms ok 0
peek-hook-before-branch completed 12 0ms ok 0
peek-hook-after-branch completed 12 0ms ok 0
peek-hook-at-registration completed 12 0ms ok 0
race-hook-before-probe completed 12 0ms ok 0
race-hook-after-probe completed 12 0ms ok 0
race-duplicate-delivery completed 13 0ms ok 0
attr-hook-before-step completed 11 0ms ok 0
attr-hook-after-step completed 11 0ms ok 0
attr-from-step-body completed 13 0ms ok 0
fork-hook-after-timeout completed 14 1.0m ok 0
fork-hook-before-timeout completed 14 1.0m ok 0
count-hook-after-timeout completed 17 1.0m ok 0
count-hook-before-timeout completed 20 1.0m ok 0
stale-read-step-count-fork completed 20 1.0m ok 0
stale-read-equal-step-counts completed 14 1.0m ok 0
step-vs-step-fork completed 12 0ms ok 0
step-vs-step-fork-fenced completed 12 0ms ok 0
fence-catches-benign-direction completed 12 5ms ok 0
in-flight-before-decision completed 17 1.0m ok 0
in-flight-before-decision-counted completed 17 1.0m ok 0
in-flight-after-decision completed 19 2.0m ok 0
stale-read-step-count-fork-fenced completed 20 1.0m ok 0
fork-hook-wins completed 13 1.0m ok 0
fork-timeout-wins completed 13 1.0m ok 0
unclaimed-payload-under-fork completed 17 1.0m ok 0
claimed-payload-under-fork completed 17 1.0m ok 0
writers-independent-step-bodies completed 12 0ms ok 0
writers-scripted-tempo completed 12 0ms ok 0
cancel-mid-step cancelled 7 0ms skipped 0

Full trace: world-sim.txt

Co-authored-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>

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

AI review: blocking issues found

const store = globalThis as typeof globalThis &
Record<symbol, WorldState | undefined>;

const state: WorldState = (store[StateKey] ??= { locks: new Map() });

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.

AI Review: Blocking

This snippet fails the lint rule added in this same PR. The rule accepts exactly two things: a globalSingleton(...) initializer, or a // per-copy-ok: annotation. store[StateKey] ??= { ... } is an element-access assignment, so collectDeclarations records store and assignment() reports it:

$ cat probe/src/docs-pattern.ts     # copied verbatim from this section
type WorldState = { locks: Map<string, Promise<void>> };
const StateKey = Symbol.for('@your-org/world-foo//locks/v1');
const store = globalThis as typeof globalThis &
  Record<symbol, WorldState | undefined>;
const state: WorldState = (store[StateKey] ??= { locks: new Map() });

$ node scripts/lint/module-scope-state.mjs probe
probe/src/docs-pattern.ts:4  const store  (field written)
TOTAL 1

packages/utils/src/module-scope-state.test.ts discovers every published packages/world-* at runtime, so a world added to this repo that follows this section verbatim fails its own test, and the failure text tells the author to reach for globalSingleton(), which this section deliberately does not mention. AGENTS.md:446 prescribes globalSingleton(); this page prescribes the hand-rolled key. Same block at docs/content/worlds/v4/building-a-world.mdx:261.

Either teach globalSingleton() here (it is already exported from the published @workflow/utils), or teach the rule to recognize a write rooted at a globalThis alias. The second looks worth doing on its own: packages/core/src/private.ts:23 and packages/next/src/index.ts:58 are already correct globalThis-backed code and the rule flags both.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed, and I took the second option you suggested — the rule now recognizes state rooted at globalThis, following one alias hop, so the documented two-statement shape (const store = globalThis as … then store[StateKey] ??= …) passes.

You were right that it's worth doing on its own: packages/core/src/private.ts:23 and packages/next/src/index.ts:58 were both correct globalThis-backed code being flagged. Core drops from 26 findings to 22 and next from 7 to 6, purely false positives removed.

I also did the first thing, in a smaller way: the docs section now says outright that globalSingleton() from @workflow/utils does exactly this and is what the first-party worlds use, with the hand-rolled form written out so a world published outside this repo doesn't need the dependency, and notes the rule accepts either. That closes the AGENTS.md/docs inconsistency you flagged without pushing a @workflow/utils dependency onto third-party world authors.

* Both branches they guard repeat on every event, so a per-copy log would be
* the same noise the latch exists to prevent.
*/
const wsState = globalSingleton(

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.

AI Review: Blocking

Sharing this Map process-wide also shares it between two different published versions of the package, which is a coupling that did not exist before. @workflow/core depends on @workflow/world-vercel as workspace:*, which publishes as an exact version, so an install with workflow and @workflow/ai on different betas gives two copies of this package in one process. shapeVersion is 1 in both, so they meet on the same Symbol.for('@workflow/world-vercel//wsEventsTransports/v1') key, and one version's write path is handed a WsEventsTransport constructed by the other version's class.

Staged two copies of the built package (b gets one extra method to stand in for a version difference) and imported both in one process:

distinct module instances:                       true
A got the same object B created:                 true
object A holds is an instance of A's own class:  false
object A holds carries B-only method:            true

Replacing globalSingleton(...) with a plain object in both copies, which is main today:

A got the same object B created:                 false
object A holds is an instance of A's own class:  true

events-v4.ts:1086 then calls transport.request(...) on that foreign instance and parses the reply against its own frames.ts schema. There is no protocol-version negotiation in this client, so a frame-format change between two co-resident betas has nothing to catch it. Pre-PR the same skew cost a second socket; post-PR it is a silent protocol mismatch.

shapeVersion cannot cover this: it versions the container ({ transports, loggedWsProxyFallback, loggedWsInUse }), which is stable, while the hazard is in the contents. The same applies to eventsRecycler in http-client.ts:539, where the shared recycler's createEventsDispatcher closure comes from whichever copy won the race, so one version's undici/H2 agent options silently apply to both.

The pools and latches genuinely want to be shared; state holding instances of module-local classes does not. ./version.js already exports the version string, so keying that one entry on it (`@workflow/world-vercel//wsEventsTransports@${version}`) keeps the layer fix and drops the cross-version handoff.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in bd5b2a4-ish (latest push) — this was the right call and I had missed it entirely.

The registry and the events dispatcher recycler are now keyed by package version:

`@workflow/world-vercel//wsEventsTransports@${version}`
`@workflow/world-vercel//eventsDispatcherRecycler@${version}`

Your framing of why shapeVersion can't cover it is exactly right and I've put it in the comment at the call site: the container shape is stable, the hazard is in the contents. Two co-resident versions now keep separate registries, which costs a second socket — the same thing that happened before this package was bundled, so it is not a regression against any state we've shipped.

I left the plain connection pools in http-client.ts:22 unversioned, per your own point that pools and latches genuinely want sharing: they hold no module-local behavior and cross-copy keep-alive reuse is the reason they exist. The recycler is different precisely because it closes over this copy's createEventsDispatcher, so I took that one.

}

/** Module-scope `const`/`let` bindings in `source`, keyed by name. */
function collectDeclarations(source) {

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.

AI Review: Note

collectDeclarations plus the same-file visit walk means the rule catches the shape these two worlds happen to be written in, rather than the class. Three probes, all clean:

src/registry.ts:  export const transports = new Map<string, number>();
src/consumer.ts:  import { transports } from './registry.js';
                  export function open(id: string) { transports.set(id, 1); }

src/static-field.ts:  export class Registry {
                        static transports = new Map<string, number>();
                        static open(id: string) { Registry.transports.set(id, 1); }
                      }

src/handed-off.ts:  const cache = new Map<string, number>();
                    function fill(t: Map<string, number>, id: string) { t.set(id, 1); }
                    export function open(id: string) { fill(cache, id); }

$ node scripts/lint/module-scope-state.mjs probe
TOTAL 0

The first is the shipped bug exactly, with the registry and its mutators in separate files, which is ordinary module hygiene rather than an exotic case. The second is module-scope state the collector never looks at (source.statements only sees VariableStatement). Cross-file needs whole-package resolution, which is a lot for a heuristic, but the class-static case is a few lines here, and flagging any module-scope Map/Set/array literal that is exported regardless of where it is written would close the first without resolution.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed two of the three, and documented the third as a known gap.

Class statics: collectDeclarations now walks ClassDeclaration members for static property declarations, attributed to the class name so Registry.transports.set(…) resolves. Your probe reports static Registry.transports.

Cross-file: took your suggestion, narrowed slightly to keep false positives down. An exported binding initialized to an empty collection (new Map(), new Set(), []) is now a finding on its own, with no write required in the file. Emptiness plus the export is the signal — an exported non-empty new Map([['a', 1]]) is a lookup table and stays clean (also a fixture). Your registry.ts/consumer.ts probe now reports transports (exported empty collection).

Handed off to a function (fill(cache, id)) still passes. That needs real dataflow, so I've written it into the rule's header as an explicit limitation rather than leaving it implied. The header now has a 'what it does not see' paragraph.

All three probes plus the non-empty case are fixtures now, so the behavior is pinned in both directions.

import ts from 'typescript';

/** Methods that mutate the receiver in place. */
const MUTATORS = new Set([

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.

AI Review: Note

The mutation check has no notion of when the mutation happens, so a table populated during module evaluation is indistinguishable from one written per request:

const BASE64_LOOKUP = new Uint8Array(256);
for (let i = 0; i < 64; i++) BASE64_LOOKUP[i] = i;

flags as const BASE64_LOOKUP (field written). That is packages/core/src/vm/uint8array-base64.ts:59, and per-copy is entirely harmless there: each copy computes the same bytes at init. Neither world has one of these today, so the rule reports zero, but the first contributor who adds a precomputed lookup or a frozen config map gets a failure whose only exit is // per-copy-ok:. Once that annotation starts appearing on declarations with no per-copy hazard it stops meaning anything, which is the failure mode for a rule whose whole value is that its escape hatch is rare. Restricting the walk to mutations reachable from a function body (skipping top-level statements) would keep the precision.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. Mutations in top-level statements no longer count — only writes reachable from a function body do.

Your BASE64_LOOKUP example is the exact test I added (packages/utils/src/module-scope-state.test.ts, 'ignores a table filled once at module evaluation'). The reasoning I wrote into the rule header is yours: a write at module evaluation runs identically in every copy, so it costs memory and nothing else; divergence needs a write that can happen later, per request.

Agreed on the failure mode you describe — an escape hatch that starts appearing on harmless declarations stops meaning anything. That was the strongest argument for fixing this now rather than waiting for the first contributor to hit it.

Comment thread AGENTS.md
### Observability data hydration
`packages/core/src/observability.ts` contains `hydrateResourceIO` which strips certain fields (like `executionContext`) before UI display. If you need to display data from stripped fields, extract it before the stripping occurs.

### World packages must not hold mutable module state

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.

AI Review: Note

Worth a sentence here on why the rule stops at packages/world-*. @workflow/core is statically imported into the same server build and has always been bundled, and this PR's own reasoning (a run started from a Server Component and consumed in a route handler puts live copies in both the ssr and app-route graphs) applies to it unchanged. It reports 26:

$ node scripts/lint/module-scope-state.mjs packages/core
TOTAL 26
packages/core/src/runtime/step-single-flight.ts:32  const inFlightSteps  (`.set()`)
packages/core/src/vm/script-cache.ts:63             const scriptCache    (`.delete()`)
packages/core/src/serialization/workflow-vm.ts:20   let _encoder         (reassigned)
packages/core/src/runtime/start.ts:83               let hasWarnedLatestNoOp (reassigned)
...

I spot-checked several and they look wasteful rather than wrong: registeredSteps (private.ts:28) is already globalThis-backed, the compile and single-flight caches are only reached from /flow so they stay in one layer, and the duplicated cbor encoders and warn-once latches cost memory and a repeated log. So I am not asking for core in this PR. But as written, someone reading "enforces this across every published packages/world-*" alongside "the class of bug behind it" in the PR description will assume core is covered. packages/next reports 7 and packages/cli 4 for the same reason.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added. AGENTS.md now states where the sweep stops and why, rather than leaving 'every published packages/world-*' to be read as covering core.

I used your spot-check verbatim, since it's more useful than a bare caveat: the step registry is already globalThis-backed, the compile and single-flight caches are only reached from /flow so they stay in one layer, and the remainder cost a duplicated encoder or a repeated warn-once log — wasteful rather than wrong, which is why core isn't gated. @workflow/next and @workflow/cli noted as the same case. Widening the sweep is tracked in #3729, and the note ends by saying a new mutable module-scope binding in core should be treated as suspect even though nothing fails the build.

The globalThis fix from your other comment also brings core down to 22 and next to 6, so those numbers are now closer to the genuine ones.

@@ -1,3 +1,7 @@
export {
globalSingleton,
resetGlobalSingletonForTest,

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.

AI Review: Nit

resetGlobalSingletonForTest lands on the public barrel of a published package. Its own doc comment says it is a test seam and that production code must not call it, because other copies keep writing to the object it discards. A ./test-support subpath export, or at least an @internal tag, keeps that out of the documented surface.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Tagged @internal. I stopped short of a ./test-support subpath: @workflow/utils has no such split today, and adding one export condition for a single four-line function felt like more surface than it removes. Happy to do it if you'd rather have the hard boundary.

Adopted from #3666 by @MintedKenny, which implements #3665 and could not run
CI as a fork PR. One line of behavior: `workflowEntrypoint`'s lazy handler
init calls `getWorld()` rather than `getWorldHandlers()`.

`getWorldHandlers()` owns a second, build-time-safe cache, so calling it from
the runtime route built a *second* World in the same process. That costs a
stateful World duplicate resources on every instance — world-postgres eagerly
constructs a `pg.Pool` (default `max: 10`) and a nested world-local World in
`createWorld()`, so self-hosted users have been paying for two of each — and,
for a bundled world package, the two Worlds are built by two different module
copies, which is the mechanism behind the WS transport regression the rest of
this branch contains.

The public `getWorldHandlers()` and its separate build-time cache are
unchanged; only the runtime route stops using it.

Kept from the original: the regression test asserting the factory runs exactly
once, and the api-reference wording (re-applied over #3704's list punctuation).
Not taken: renaming the `workflow.route.get_world_handlers` span. It is a
distinct span from the per-request `workflow.route.get_world` at the top of the
flow route, and reusing that name would collide with it in traces and in
`runtime-trace-mode.test.ts`; a comment records why the name outlived the call.

Co-authored-by: Kenneth <kenneth@standardforensics.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two blocking findings, both real:

- **Cross-version state sharing** (`ws-transport.ts`). A process can hold two
  *published versions* of `@workflow/world-vercel` (a transitive dependency
  pinning an older `@workflow/core`, which depends on this package by exact
  version). Both wrote to the same unversioned `Symbol.for` key, so one
  version's write path could be handed a `WsEventsTransport` built by the
  other's class and frame against a protocol it may not share — with no version
  negotiation on the socket to catch it. `shapeVersion` cannot express this: the
  container is stable, the hazard is its contents. The registry and the events
  dispatcher recycler are now keyed by package version. The plain connection
  pools stay unversioned; sharing those across copies is the point.

- **The documented pattern failed the rule this PR adds.** The custom-world docs
  teach `store[StateKey] ??= …`, which the rule flagged as a field write. It now
  recognizes state rooted at `globalThis`, following one alias hop, which is
  also what `core/private.ts:23` and `next/src/index.ts:58` are already doing
  correctly (core drops 26 findings to 22, next 7 to 6). The docs also now say
  outright that `globalSingleton()` is the same thing, since AGENTS.md
  prescribes it and the page did not mention it.

Rule precision, from the review's probes:

- `.mts`/`.cts` are scanned. `@workflow/world-testing` is authored in `.mts`, so
  its entry in the sweep was passing vacuously — with the walk fixed it reports
  a real finding, now annotated (it is a standalone `serve()` entry).
- Mutations in top-level statements no longer count. A table filled at module
  evaluation is identical in every copy; divergence needs a later write.
- `static` class fields are collected, attributed to the class name.
- An *exported* binding initialized to an empty collection is a finding on its
  own, which approximates the cross-file case the walk cannot resolve.

Six fixtures pin the new behavior. The rule's header now states what it does not
see, and AGENTS.md states where the sweep stops and why core is not gated yet.

Also tags `resetGlobalSingletonForTest` `@internal`.

@shalabhc shalabhc left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Looks like 'workflow/core' is not covered here.
Clanker found many guard violations:

The sweep covers published packages/world-*. @workflow/core is excluded — yet the PR's own doc comment says core "has always been bundled", which makes it the longest-standing instance of this bug class. I ran their rule against it:

packages/world-vercel — 0
packages/world-local  — 0
packages/core         — 26
packages/utils        — 1

Most are caches (waste, not wrongness), but one looks like the same bug with worse consequences: step-single-flight.ts holds inFlightSteps as a module-scope Map, and its own doc scopes the guarantee to "already in flight in this process" while explicitly ruling cross-instance duplicates out of scope. Per-layer copies quietly break the in-process half — two layers could each run one "winner" for the same run + correlation id. I have not proven it reachable (that needs the same step driven from two layers in one process), but it is exactly the shape that bit the socket, and it deserves a look before this PR's rule declares the class covered. Worth noting registeredSteps in private.ts is already globalThis-backed, so core's authors knew the hazard — which makes core's omission from the sweep more surprising, not less.

The static-field support added in the previous commit keyed `declared` on the
class name, so a class carrying more than one mutable static reported one
finding instead of one per field, and labelled the survivor with whichever
mutation was seen first. On a two-static fixture it reported
`static Registry.latch  (`.set()`)`: the name of one field, the reason
belonging to the other, pointing the reader at the wrong line.

Key static fields `Class.field` and resolve a write to the same shape, via a
new `memberPath()` that takes the first two segments of a member chain and
tries that key before the bare root identifier. Two follow-ons fall out of
having the path:

- `this.field` inside a `static` member resolves to the class, which is the
  ordinary way to write the mutation. `staticClassOf()` returns nothing for an
  instance member, where `this` is an instance and the state is per-instance
  rather than per-copy, and nothing inside a nested `function`, which rebinds
  `this`.
- `state.count++` is now a finding, like the `state.count += 1` that
  `assignment()` already reported.

Fixtures pin all four, including the instance-field case that must stay clean.
The four world packages still report zero, and the extracted `recordMutation()`
keeps the file at its previous two Biome complexity warnings.

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

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

AI review: no blocking issues.

Re-reviewed c87dea39f5 and 3c628bad05. All six findings from the previous round are addressed, and I verified each against the code rather than the description.

Verified fixed

  • Docs pattern vs. the rule. isGlobalThisBacked accepts an initializer that reaches globalThis, including through the two-statement alias form. The section's snippet now scans clean, and so do the already-correct core/src/private.ts:23 and next/src/index.ts:58.
  • Cross-version coupling. Staged two copies of the built package with distinct version.js values and confirmed both directions: different versions get separate registries and each keeps its own class's instances; same version across two module copies still shares, so the bundler-layer fix is intact.
a=beta.39 b=beta.40   same object: false   own class: true
a=beta.39 b=beta.39   same object: true    own class: false
  • Init-time vs request-time. The inFunction gate drops the lookup-table false positive; the BASE64_LOOKUP shape is clean.
  • .mts/.cts. Confirmed world-testing/src is entirely .mts, so that sweep was genuinely vacuous before. It now scans, and the per-copy-ok on the test server's counter is honest: it is a standalone entry.
  • Cross-file registries, via the exported-empty-collection heuristic. The two-file split of the shipped bug is now reported.
  • Core's scope boundary is written down in AGENTS.md with the spot-check reasoning, which is what I was after.

Fixed directly in 00338ea

The static-field support keyed declared on the class name, so a class with more than one mutable static reported a single finding and labelled it with the wrong field's mutation:

static Registry.latch  (`.set()`)      # latch is assigned; transports is .set()

One went unreported, and the one that survived pointed at the wrong line. Now keyed Class.field, with a memberPath() that resolves a write to the same shape. Two things fell out of having the path: this.field inside a static member resolves to the class (and correctly does not for an instance member or a nested function), and state.count++ is now caught like the state.count += 1 that was already reported. Four fixtures pin those, including the instance-field case that must stay clean. All four worlds still report zero, and the extracted recordMutation() keeps the file at its pre-existing two Biome complexity warnings rather than adding a third.

On 3c628bad05

Reviewed separately since it is new scope. getWorld() and getWorldHandlers() are structurally identical apart from the cache symbol, and World satisfies Pick<World, 'createQueueHandler' | 'specVersion'>, so the handler's view is unchanged. It also drops a detached-method hazard: the old path passed createQueueHandler off an object literal, the new one passes the World itself. The lazy init is inside the request handler, so nothing populates the runtime cache at build time, and getWorldHandlers() stays exported with its own cache for build-time callers. Reusing the workflow.route.get_world_handlers span name is a little odd now, but the collision argument holds and the comment records it.

Local runs

utils 139, world-vercel 552, world-local 558, world-testing 16, core/runtime-world-singleton 1: all pass. Full turbo build green across 45 packages, typecheck clean. One world-postgres spec.test.ts > runs an addition failure locally that passed 15/15 on re-run; unrelated to the lint script, which nothing in that suite imports.

CI was still working through the E2E matrix with no failures when I looked. Approving on the strength of the above; worth a glance at the required aggregate before merge.

`@workflow/core` is bundled into the host server build the same way the worlds
are, and always has been — the original repro measured three live copies in
every arm, including the pre-#3493 external one. One instance is not reachable:
layers cannot share a module, and core cannot be external because it *is*
workflow code (`runtime/start.ts:253` and nine methods in `runtime/run.ts` are
`'use step'`), so it must go through the SWC loader. The Next integration
already encodes that rule by removing workflow-bearing packages from
`serverExternalPackages`.

So the duplication stays and the hazard is removed instead, everywhere the
duplication can happen.

`@workflow/core` (22 findings to 0): warn-once latches in `constants.ts`,
`start.ts` and `telemetry.ts`; the source-map tracer cache; the VM script cache;
the QuickJS compiled-assets and baseline caches; the dev-server port cache (its
own comment already said "per process"); the text codecs; the zstd browser
decoder; and the `useStep` closure brand, where a function marked by one copy
was invisible to another.

The one with teeth was `step-single-flight.ts`: a per-copy map is not
single-flight. Two invocations reaching it through different layers would each
believe they were alone in the process and both run the step body, silently
degrading in-process dedup to the cross-process residual its own doc scopes out
to the ownership lease.

Also `@workflow/world` (a warn-once set, hand-rolled onto `globalThis` to keep
that package dependency-free), `@workflow/ai` (the lazy OTel API), and
`@workflow/nest` (bootstrap config in a module-level `let` and two static class
fields — configure one copy, read another, and the controller is unconfigured
for the life of the process).

Five sites are deliberately per-copy and now say why: state keyed on objects
that never cross copies (the barrier safety-net `WeakSet`, the QuickJS pending
byte `WeakMap`), the synchronously-scoped guest-code sink, and the OTel
diagnostic that reports what *this* copy sees.

The sweep now covers all of it. Packages with a single module graph stay out
(build-time code, the CLI, the o11y UI, the test runner) and AGENTS.md records
which and why.

Found while doing this: two static fields on one class collapsed into a single
entry in the rule, so `WorkflowModule.options` was invisible behind
`WorkflowModule.outDir`. Statics are now keyed `Class.field`.
The hand-rolled form trips Biome, as it does in `packages/core/src/private.ts`,
which carries the same suppression. Restructuring it into a helper function
instead would hide the state behind a call the module-scope rule cannot follow,
so the binding would stop being recognized as off-module and the package would
report a finding for correct code.
* origin/main:
  Run the test suites CI was silently skipping (#3733)
  [e2e] Change race repro hook poke to soft-degrade instead of hard-stop at budget (#3561)

@shalabhc shalabhc left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approving, but do we also need @workflow/utils?

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

AI review: no blocking issues. Approval stands over the new head (9979bb56fe).

My earlier approval was on 00338ea32a, and the branch has since grown a lot, so here is a review of what came after it.

6b991a5317 — expansion to every bundled package

This takes the core-scope note further than I asked, converting @workflow/core (22 to 0), plus @workflow/world, @workflow/ai and @workflow/nest, and adds them to an explicit BUNDLED_RUNTIME_PACKAGES list. I checked the parts that carry risk rather than the count:

  • step-single-flight.ts is the one with teeth, and the commit is right that a per-copy map is not single-flight. The key is runId:correlationId, fully qualified, so sharing introduces no cross-contamination.
  • The hot-path caches are keyed on content, not on anything copy-local: scripts.byCode and baselines.byKey on the bundle source, quickjsAssets.promise on nothing. Sharing them is a straight win, and BASELINE_CACHE_MAX_ENTRIES now bounds one cache instead of one per layer, so peak memory goes down rather than up.
  • The five per-copy-ok sites are correctly reasoned, and hardened.ts is the one I would have gotten wrong: activeStats and reportedProxies are armed and cleared around a synchronous call, so sharing them would actually be a bug (two copies recording concurrently would clobber each other's sink). Keeping them per-copy is right.
  • useStepClosures on globalThis is correct: functions cross copies freely, so a brand applied by one copy has to be visible to another.

On the exclusion list: next is described as build-time code, but the package does have a ./runtime export. It happens not to matter, because packages/next/src/runtime.ts is a bare export * from '@workflow/core/runtime' and pulls in no local module, so none of its 6 remaining findings can reach a server bundle. The conclusion holds; the parenthetical is just looser than the reasoning behind it. Worth a word if you touch that paragraph again.

Also verified

The static-field keying you hit independently is the same defect I fixed in 00338ea32a; that commit is an ancestor here, and memberPath() plus Class.field keying is intact. WorkflowModule.options is no longer hidden behind WorkflowModule.outDir. I had a fix queued for the noAssignInExpressions error at env-config.ts and dropped it when c95c5ca02d landed the same single-line suppression. Worth knowing for next time: a suppression whose reason continues onto following comment lines is not recognized, so the reason has to sit on the biome-ignore line itself.

Local runs on 9979bb56fe

All 30 test tasks pass: core 106 files, world 13 (newly enabled by #3733), ai 12, nest 3, utils 11, world-vercel 28, world-local 18, world-testing 3. Full turbo build 45/45, turbo typecheck 43/43, repo-wide biome ci clean, and scripts/check-no-unrun-tests.mjs passes. The rule reports zero across all nine covered packages.

CI: the 16 red lanes are inherited from main, not from this branch

Every failure is packages/core/e2e/e2e.test.ts > e2e > AbortController, timing out at 60000ms rather than asserting. main's latest tests.yml run (32535198498) has 19 failing jobs across the same lane set, including E2E Required Check itself, and its last three runs are red. The failing test rotates within the group between runs, which is why it does not look identical lane to lane:

failing test
this branch, vite - quickjs abortAnyInStepWorkflow, abortListenerWorkflow
main, vite - quickjs abortDeterministicBranchFromStepWorkflow

E2E Python Conformance and (python - node) are red in the same main runs for the separate reason that #3634 moved the JS spec version to 7 while the Python SDK still caps at 6.

So nothing here to fix on this branch, and the required aggregate cannot go green until main is. Worth deciding separately whether the AbortController group gets fixed or quarantined, since it is currently blocking every PR.

@shalabhc asked on review whether `@workflow/utils` needs this too. It does,
and so do three others: `utils`, `errors`, `serde` and `workflow` all end up in
the host application's server build and none were in the sweep. All four report
zero today, which is exactly the state `world-testing` appeared to be in before
the `.mts` walk was fixed and it turned out to have a real finding. Being clean
and being *checked* are different properties, and only the second one survives
the next contributor.

`sideEffects: false` on `@workflow/utils`: verified that every module in the
package only declares (no import-time work), so a bundler can now drop the
unused parts of the barrel instead of keeping all ~64 KB of it because three
packages import one 476-byte function.
@pranaygp

Copy link
Copy Markdown
Contributor Author

Approving, but do we also need @workflow/utils?

Yes, and thanks — that was a real gap. @workflow/utils ends up in the host application's server build like the rest, and it was not in the sweep. Neither were @workflow/errors, @workflow/serde, or workflow itself. All four are now covered.

All four report zero today, which is exactly the state @workflow/world-testing appeared to be in earlier in this PR — until the walk was taught to read .mts and it turned out to have a real finding all along. Being clean and being checked are different properties, and only the second one survives the next contributor.

Also took the other half of the question, on the dependency itself: @workflow/utils now declares sideEffects: false. I verified every module in the package only declares (no import-time work), so a bundler can drop the unused parts of the barrel rather than keeping all ~64 KB of it because three packages import one 476-byte function.

The sweep list is now core, workflow, world, utils, errors, serde, ai, nest, plus every published packages/world-* discovered at runtime. Out of scope, and recorded in AGENTS.md with the reasoning: build-time code (next, builders, sveltekit), the CLI, the o11y UI, the test runner, and private packages — all single-module-graph, where the hazard cannot occur.

@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for f771585 (AI decision).

This is a sprawling change that mixes a real fix with new API surface and new tooling: it adds a publicly exported globalSingleton() to @workflow/utils (with a minor changeset), a brand-new scripts/lint/module-scope-state.mjs rule plus per-package guard tests across eight packages, new docs sections codifying the rule, and a sideEffects: false build optimization — none of which are defect fixes. Its headline symptom is also absent on stable, which has no packages/world-vercel/src/ws-transport.ts, create-run-id.ts, or core/src/runtime/step-single-flight.ts, so the cherry-pick would need substantial rework rather than applying as-is. The genuinely stability-relevant parts a human could split out and force through are the globalThis conversions of world-local's runFileLocks (a duplicated mutex stops mutually excluding), world-vercel's http-client connection pools and long-poll negative cache, and the workflowEntrypoint change to build its handler from getWorld() so a process creates one World instead of two.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

f771585486b3019c8d68211b158dfeffc9e5ebe8

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.

workflowEntrypoint initializes a separate World before getWorld

4 participants