You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The non-Vercel branch of runWorkflow calls getPortLazy() on every replay to locate the local dev server port. getPort() rediscovers the port each call by querying the OS for the process's listening sockets — on macOS that shells out to lsof (~60ms). This is paid on every replay and dominates local time-to-first-step.
The dev server's port is stable for the lifetime of the process, so this caches the resolved port per process and reuses it.
A transient undefined (server not listening yet on the very first replay) is not cached, so discovery retries until a concrete port appears.
Concurrent first calls share a single in-flight lookup (no double lsof).
Impact (local replay, macOS)
Measured against world-local with a one-step workflow, the getPortLazy phase of the replay window:
before
after
getPortLazy per replay (warm)
~64 ms
~0.02 ms (cached)
first call (cold)
~64 ms
~64 ms (paid once)
This is a local-dev-only path — on Vercel the port lookup is skipped entirely (isVercel short-circuit), so production is unaffected.
Tests
New get-port-lazy.test.ts verifies the caching contract via an injected resolver:
resolves once and reuses on subsequent calls (single OS query),
dedupes concurrent first calls into one resolution,
does not cache undefined (retries until a concrete port appears).
The non-Vercel branch of runWorkflow calls getPortLazy() on every replay
to find the local dev server port. getPort() rediscovers the port each
call by querying the OS for the process's listening sockets — on macOS
that spawns lsof (~60ms), paid on every replay and dominating local
time-to-first-step.
The port is stable for the process lifetime, so resolve it once and
reuse it. A transient undefined (server not yet listening) is not cached
so discovery retries; concurrent first calls share one in-flight lookup.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The reason will be displayed to describe this comment to others. Learn more.
Clean, well-scoped optimization with a clear rationale and good test coverage. The caching logic is correct: the fast path / in-flight dedup runs entirely synchronously before the first await, so concurrent first calls genuinely share one lookup; undefined is never cached, so the not-yet-listening window self-recovers; and the _getPort ?? (async () => undefined) fallback is actually a small robustness win over the previous _getPort!() (which would have thrown if the module loaded without a getPort export). I traced the three test scenarios against the exact logic and they pass.
Two non-blocking notes inline. Plus one drive-by that's out of this diff's scope: the comment at workflow.ts:147-150 still describes the cache as globalThis.__wkf_getPort, which hasn't matched the implementation (module-level _getPort, now _cachedPort) for a while — since this PR is exactly about that caching, it'd be a natural place to freshen that comment.
The reason will be displayed to describe this comment to others. Learn more.
Worth stating the core assumption explicitly: this caches the first concrete port for the whole process and never re-resolves. The previous per-replay getPort() had a (theoretical) self-healing property — if one call resolved the wrong port, a later replay could correct it; after this change the first concrete value is locked in.
In practice this is almost certainly fine: the runtime only runs inside the already-listening dev-server process, and getAllPorts() is documented as returning a deterministic order, so repeated calls return the same [0] anyway. The one case I'd sanity-check is multi-listener processes (e.g. node --inspect adds a :9229 listener): getPortLazy resolves via getPort() (first listening port), not the probed getWorkflowPort(), so if a non-dev-server socket ever sorts first, that value is now pinned for the process rather than re-evaluated each replay. That's a pre-existing getPort weakness, not something this PR introduces — just flagging that caching removes the per-call escape hatch. Non-blocking.
The reason will be displayed to describe this comment to others. Learn more.
Optional / FYI: setPortResolverForTesting and resetPortCacheForTesting are exported from the production module, so they ship in the built package. They're not re-exported from any public barrel (only reachable via a deep ./runtime/get-port-lazy.js import) and the JSDoc explains why injection is the only deterministic seam, so I think this is the right tradeoff — just noting the test-only surface is now part of the shipped output.
Minor latent footgun in the seam (not hit by the current tests, which all fully await): if a future test calls getPortLazy() without awaiting it, resetPortCacheForTesting() in afterEach clears _inFlight but can't cancel the already-scheduled .then, so a late resolution could set _cachedPort after the reset and bleed into the next test. A one-line note on these helpers that callers must let in-flight lookups settle before resetting would head that off.
Address PR review nits on the local port cache:
- Document that the first concrete port is pinned for the process and
why that is safe (runtime runs inside the listening dev server;
getAllPorts ordering is deterministic).
- Note on resetPortCacheForTesting that callers must let in-flight
lookups settle before resetting to avoid a late .then repopulating
the cache across tests.
- Fix the stale getPortLazy comment in workflow.ts that still referenced
the old globalThis.__wkf_getPort cache mechanism.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit modifies packages/core/src/runtime/get-port-lazy.ts, which does not exist on stable — on stable, workflow.ts imports getPort from @workflow/utils/get-port and calls it directly rather than through a getPortLazy lazy-loader. The change builds on the main-only getPortLazy lazy-loading mechanism, so the caching optimization isn't applicable and wouldn't cherry-pick cleanly onto stable.
To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:
…testing
* origin/main:
perf(core): decouple workflow VM seed/clock from startedAt (#2525)
[world-local] [core] Cache local dev server port per process (#2522)
Show pending runs as gray animated stripes in trace viewer (#2520)
[world-vercel] Route v4 event requests through global fetch (#2514)
[core] Send workflowName with step events (#2511)
Stamp run IDs on world spans (#2508)
Reject empty-string hook tokens in createHook() (#2490)
perf(core): cache compiled workflow-bundle vm.Script across replays (#2471)
perf(core): drain consumable replay events synchronously (#2473)
perf(core): lazy inline step start (save one world round-trip per step) (#2478)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The non-Vercel branch of
runWorkflowcallsgetPortLazy()on every replay to locate the local dev server port.getPort()rediscovers the port each call by querying the OS for the process's listening sockets — on macOS that shells out tolsof(~60ms). This is paid on every replay and dominates local time-to-first-step.The dev server's port is stable for the lifetime of the process, so this caches the resolved port per process and reuses it.
undefined(server not listening yet on the very first replay) is not cached, so discovery retries until a concrete port appears.lsof).Impact (local replay, macOS)
Measured against
world-localwith a one-step workflow, thegetPortLazyphase of the replay window:getPortLazyper replay (warm)This is a local-dev-only path — on Vercel the port lookup is skipped entirely (
isVercelshort-circuit), so production is unaffected.Tests
New
get-port-lazy.test.tsverifies the caching contract via an injected resolver:undefined(retries until a concrete port appears).🤖 Generated with Claude Code