From 503ca678a7092cc05902a4b81ed65adc729088ec Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Thu, 18 Jun 2026 15:02:33 -0700 Subject: [PATCH] perf(core): cache local dev server port per process (backport #2522) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The non-Vercel branch of runWorkflow calls the local-port resolver on every replay to find the 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. Backport of #2522. On stable, getPort is statically imported (the main-only getPortLazy createRequire/Turbopack indirection does not exist here), so the cache wraps the static resolver instead. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/local-port-cache.md | 5 ++ .../core/src/runtime/get-port-lazy.test.ts | 65 +++++++++++++++++ packages/core/src/runtime/get-port-lazy.ts | 69 +++++++++++++++++++ packages/core/src/workflow.ts | 7 +- 4 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 .changeset/local-port-cache.md create mode 100644 packages/core/src/runtime/get-port-lazy.test.ts create mode 100644 packages/core/src/runtime/get-port-lazy.ts diff --git a/.changeset/local-port-cache.md b/.changeset/local-port-cache.md new file mode 100644 index 0000000000..66d546e2c2 --- /dev/null +++ b/.changeset/local-port-cache.md @@ -0,0 +1,5 @@ +--- +'@workflow/core': patch +--- + +Cache the local dev server port per process so workflow replays no longer re-run OS port discovery (which spawns `lsof` on macOS, ~60ms) on every replay. diff --git a/packages/core/src/runtime/get-port-lazy.test.ts b/packages/core/src/runtime/get-port-lazy.test.ts new file mode 100644 index 0000000000..0bc5dad4ac --- /dev/null +++ b/packages/core/src/runtime/get-port-lazy.test.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { + getPortLazy, + resetPortCacheForTesting, + setPortResolverForTesting, +} from './get-port-lazy.js'; + +describe('getPortLazy port caching', () => { + afterEach(() => { + resetPortCacheForTesting(); + }); + + it('resolves the port once and reuses it on subsequent calls', async () => { + let calls = 0; + setPortResolverForTesting(async () => { + calls++; + return 3000; + }); + + expect(await getPortLazy()).toBe(3000); + expect(await getPortLazy()).toBe(3000); + expect(await getPortLazy()).toBe(3000); + + // The expensive OS query (e.g. spawning `lsof`) runs only once. + expect(calls).toBe(1); + }); + + it('dedupes concurrent first calls into a single resolution', async () => { + let calls = 0; + setPortResolverForTesting(async () => { + calls++; + await new Promise((r) => setTimeout(r, 5)); + return 4000; + }); + + const results = await Promise.all([ + getPortLazy(), + getPortLazy(), + getPortLazy(), + ]); + + expect(results).toEqual([4000, 4000, 4000]); + expect(calls).toBe(1); + }); + + it('does not cache undefined, and caches the first concrete port', async () => { + let calls = 0; + setPortResolverForTesting(async () => { + calls++; + // Server not listening yet on the first two calls, then it comes up. + return calls < 3 ? undefined : 5000; + }); + + // Transient undefined must not poison the cache — each call retries. + expect(await getPortLazy()).toBeUndefined(); + expect(await getPortLazy()).toBeUndefined(); + // First concrete port resolves and is cached from here on. + expect(await getPortLazy()).toBe(5000); + expect(await getPortLazy()).toBe(5000); + + // 3 real queries (the two undefined + the one that resolved); the final + // call is served from cache. + expect(calls).toBe(3); + }); +}); diff --git a/packages/core/src/runtime/get-port-lazy.ts b/packages/core/src/runtime/get-port-lazy.ts new file mode 100644 index 0000000000..e38e908bb3 --- /dev/null +++ b/packages/core/src/runtime/get-port-lazy.ts @@ -0,0 +1,69 @@ +import { getPort } from '@workflow/utils/get-port'; + +// Per-process cache of the resolved port. The workflow dev server listens on a +// stable port for the lifetime of the process, but `getPort()` rediscovers it +// on every call by querying the OS for the process's listening sockets — on +// macOS that shells out to `lsof` (~60ms), which the runtime pays on EVERY +// workflow replay (the non-Vercel branch of `runWorkflow`). Since the port does +// not change within a process, resolve it once and reuse it. `_inFlight` +// dedupes concurrent first calls so discovery never runs more than once. +// +// The first concrete port is pinned for the lifetime of the process — there is +// no per-call re-resolution. This is safe because the runtime only runs inside +// the already-listening dev-server process, and `getPort()` -> `getAllPorts()` +// returns a deterministic order, so repeated calls would resolve the same port +// anyway. +let _getPort: () => Promise = getPort; +let _cachedPort: number | undefined; +let _inFlight: Promise | undefined; + +export async function getPortLazy(): Promise { + // Fast path: already resolved a concrete port for this process. + if (_cachedPort !== undefined) { + return _cachedPort; + } + // A discovery is already running — share it rather than starting a second. + if (_inFlight) { + return _inFlight; + } + + _inFlight = _getPort() + .then((port) => { + // Only cache a concrete port. A transient `undefined` (e.g. the server is + // not listening yet on the very first replay) must not poison the cache — + // leaving it unset lets the next call retry discovery. + if (typeof port === 'number') { + _cachedPort = port; + } + return port; + }) + .finally(() => { + _inFlight = undefined; + }); + return _inFlight; +} + +/** + * Resets the per-process port cache. Intended for tests; not used on the hot + * path. Callers must let any in-flight lookup settle (await the pending + * `getPortLazy()` call) before resetting: clearing `_inFlight` here does not + * cancel an already-scheduled resolution, so a late `.then` could otherwise + * repopulate `_cachedPort` after the reset and bleed into the next test. + */ +export function resetPortCacheForTesting(): void { + _getPort = getPort; + _cachedPort = undefined; + _inFlight = undefined; +} + +/** + * Installs a fake port resolver and clears the cache. Test-only seam used to + * exercise the caching contract deterministically without touching the OS. + */ +export function setPortResolverForTesting( + fn: () => Promise +): void { + _getPort = fn; + _cachedPort = undefined; + _inFlight = undefined; +} diff --git a/packages/core/src/workflow.ts b/packages/core/src/workflow.ts index 300dc1947b..10463c37c9 100644 --- a/packages/core/src/workflow.ts +++ b/packages/core/src/workflow.ts @@ -6,7 +6,6 @@ import { WorkflowRuntimeError, } from '@workflow/errors'; import { withResolvers } from '@workflow/utils'; -import { getPort } from '@workflow/utils/get-port'; import { parseWorkflowName } from '@workflow/utils/parse-name'; import type { Event, WorkflowRun } from '@workflow/world'; import * as nanoid from 'nanoid'; @@ -17,6 +16,7 @@ import type { QueueItem } from './global.js'; import { ENOTSUP, WorkflowSuspension } from './global.js'; import { runtimeLogger } from './logger.js'; import type { WorkflowOrchestratorContext } from './private.js'; +import { getPortLazy } from './runtime/get-port-lazy.js'; import { dehydrateWorkflowReturnValue, hydrateWorkflowArguments, @@ -101,7 +101,10 @@ export async function runWorkflow( // Get the port before creating VM context to avoid async operations // affecting the deterministic timestamp const isVercel = process.env.VERCEL_URL !== undefined; - const port = isVercel ? undefined : await getPort(); + // The resolved port is cached per process (see get-port-lazy.ts), so this + // is cheap on replays after the first — `getPort()` otherwise re-runs OS + // port discovery (spawning `lsof` on macOS, ~60ms) on every replay. + const port = isVercel ? undefined : await getPortLazy(); const { context,