Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/local-port-cache.md
Original file line number Diff line number Diff line change
@@ -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.
65 changes: 65 additions & 0 deletions packages/core/src/runtime/get-port-lazy.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
69 changes: 69 additions & 0 deletions packages/core/src/runtime/get-port-lazy.ts
Original file line number Diff line number Diff line change
@@ -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<number | undefined> = getPort;
let _cachedPort: number | undefined;
let _inFlight: Promise<number | undefined> | undefined;

export async function getPortLazy(): Promise<number | undefined> {
// 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<number | undefined>
): void {
_getPort = fn;
_cachedPort = undefined;
_inFlight = undefined;
}
7 changes: 5 additions & 2 deletions packages/core/src/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading