From b5c50c1bbbd5a1aee080ffc160b5c4e63e15dd15 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Mon, 18 May 2026 10:56:28 -0700 Subject: [PATCH 1/3] fix(cli): trim overlong session names to fit unix socket path limit Long session names plus a long os.tmpdir() prefix (e.g. /var/folders/... on macOS) pushed the cli daemon's unix socket path past sun_path's 104-byte limit, and listen() failed with EINVAL. makeSocketPath now sanitizes the name for fs use and hashes the middle when the full path would overflow. Fixes https://github.com/microsoft/playwright/issues/40878 --- packages/playwright/src/util.ts | 10 ---------- packages/playwright/src/worker/testInfo.ts | 4 ++-- packages/utils/fileUtils.ts | 20 +++++++++++++++++++- tests/mcp/cli-misc.spec.ts | 9 +++++++++ 4 files changed, 30 insertions(+), 13 deletions(-) diff --git a/packages/playwright/src/util.ts b/packages/playwright/src/util.ts index ce7da5ef8bcc4..f21353c8796be 100644 --- a/packages/playwright/src/util.ts +++ b/packages/playwright/src/util.ts @@ -186,16 +186,6 @@ export function expectTypes(receiver: any, types: ('APIResponse' | 'Page' | 'Loc export const windowsFilesystemFriendlyLength = 60; -export function trimLongString(s: string, length = 100) { - if (s.length <= length) - return s; - const hash = calculateSha1(s); - const middle = `-${hash.substring(0, 5)}-`; - const start = Math.floor((length - middle.length) / 2); - const end = length - middle.length - start; - return s.substring(0, start) + middle + s.slice(-end); -} - export function addSuffixToFilePath(filePath: string, suffix: string): string { const ext = path.extname(filePath); const base = filePath.substring(0, filePath.length - ext.length); diff --git a/packages/playwright/src/worker/testInfo.ts b/packages/playwright/src/worker/testInfo.ts index 43003ee9e79d5..6250ce936a20b 100644 --- a/packages/playwright/src/worker/testInfo.ts +++ b/packages/playwright/src/worker/testInfo.ts @@ -22,11 +22,11 @@ import { captureRawStack, stringifyStackFrames } from '@isomorphic/stackTrace'; import { escapeWithQuotes } from '@isomorphic/stringUtils'; import { monotonicTime } from '@isomorphic/time'; import { createGuid } from '@utils/crypto'; -import { sanitizeForFilePath } from '@utils/fileUtils'; +import { sanitizeForFilePath, trimLongString } from '@utils/fileUtils'; import { currentZone } from '@utils/zones'; import { TimeoutManager, TimeoutManagerError } from './timeoutManager'; -import { addSuffixToFilePath, filteredStackTrace, getContainedPath, normalizeAndSaveAttachment, sanitizeFilePathBeforeExtension, trimLongString, windowsFilesystemFriendlyLength } from '../util'; +import { addSuffixToFilePath, filteredStackTrace, getContainedPath, normalizeAndSaveAttachment, sanitizeFilePathBeforeExtension, windowsFilesystemFriendlyLength } from '../util'; import { TestTracing } from './testTracing'; import { testInfoError } from './util'; import { ipc, transform } from '../common'; diff --git a/packages/utils/fileUtils.ts b/packages/utils/fileUtils.ts index 4328e5640db4f..6c06f133a0847 100644 --- a/packages/utils/fileUtils.ts +++ b/packages/utils/fileUtils.ts @@ -78,6 +78,16 @@ export function sanitizeForFilePath(s: string) { return s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-'); } +export function trimLongString(s: string, length = 100) { + if (s.length <= length) + return s; + const hash = calculateSha1(s); + const middle = `-${hash.substring(0, 5)}-`; + const start = Math.floor((length - middle.length) / 2); + const end = length - middle.length - start; + return s.substring(0, start) + middle + s.slice(-end); +} + export function isPathInside(root: string, candidate: string): boolean { const resolvedRoot = path.resolve(root); const resolvedCandidate = path.resolve(candidate); @@ -104,6 +114,9 @@ export function toPosixPath(aPath: string): string { return aPath.split(path.sep).join(path.posix.sep); } +// macOS sun_path is 104 bytes (Linux is 108) including the NUL terminator. Use the lower bound. +const UNIX_SOCKET_PATH_MAX = 103; + export function makeSocketPath(domain: string, name: string): string { const userNameHash = calculateSha1(process.env.USERNAME || process.env.USER || 'default').slice(0, 8); if (process.platform === 'win32') { @@ -113,7 +126,12 @@ export function makeSocketPath(domain: string, name: string): string { } const baseDir = process.env.PLAYWRIGHT_SOCKETS_DIR || path.join(os.tmpdir(), `pw-${userNameHash}`); const dir = path.join(baseDir, domain); - const result = path.join(dir, `${name}.sock`); + const suffix = '.sock'; + const maxNameLength = UNIX_SOCKET_PATH_MAX - dir.length - path.sep.length - suffix.length; + if (maxNameLength < 1) + throw new Error(`Socket directory path is too long (${dir.length} chars); set PLAYWRIGHT_SOCKETS_DIR to a shorter location.`); + const fsFriendlyName = trimLongString(sanitizeForFilePath(name), maxNameLength); + const result = path.join(dir, `${fsFriendlyName}${suffix}`); fs.mkdirSync(dir, { recursive: true }); return result; } diff --git a/tests/mcp/cli-misc.spec.ts b/tests/mcp/cli-misc.spec.ts index 6eab31052a470..41907ad9331c1 100644 --- a/tests/mcp/cli-misc.spec.ts +++ b/tests/mcp/cli-misc.spec.ts @@ -67,3 +67,12 @@ test('install handles browser detection', async ({ cli }) => { if (foundMatch?.[1] !== 'chrome') expect(output).toContain(`Created default config for ${foundMatch?.[1] ?? 'chromium'}.`); }); + +test('open with very long session name (issue 40878)', async ({ cli, server }) => { + // Long session names push the unix socket path past sun_path's 104-byte limit on macOS. + const longSessionName = 'awesome-coding-agent-orchestrators-with-an-overlong-suffix-for-testing'; + const result = await cli(`-s=${longSessionName}`, 'open', server.PREFIX); + expect(result.error).toBe(''); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('Page URL'); +}); From 2e154415ed44de1d0a1cf1c56edfc06308b4a190 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Mon, 18 May 2026 11:08:26 -0700 Subject: [PATCH 2/3] chore(test): rename internal PLAYWRIGHT_* env vars to PWTEST_* PLAYWRIGHT_SOCKETS_DIR, PLAYWRIGHT_SERVER_REGISTRY, and PLAYWRIGHT_DAEMON_SESSION_DIR are only consumed internally and set by test fixtures. The PLAYWRIGHT_ prefix is reserved for public APIs; switch to the PWTEST_ prefix used for other internal-only toggles. --- packages/playwright-core/src/serverRegistry.ts | 2 +- packages/playwright-core/src/tools/cli-client/registry.ts | 4 ++-- packages/utils/fileUtils.ts | 6 +++--- tests/extension/extension-fixtures.ts | 6 +++--- tests/library/browser-server.spec.ts | 2 +- tests/mcp/cli-fixtures.ts | 6 +++--- tests/mcp/cli-session.spec.ts | 2 +- tests/mcp/dashboard.spec.ts | 2 +- 8 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/playwright-core/src/serverRegistry.ts b/packages/playwright-core/src/serverRegistry.ts index 98a82863fb176..3f5ddb0a74a67 100644 --- a/packages/playwright-core/src/serverRegistry.ts +++ b/packages/playwright-core/src/serverRegistry.ts @@ -171,7 +171,7 @@ class ServerRegistry extends EventEmitter { } private _browsersDir() { - return process.env.PLAYWRIGHT_SERVER_REGISTRY || registryDirectory; + return process.env.PWTEST_SERVER_REGISTRY || registryDirectory; } private _startWatcher() { diff --git a/packages/playwright-core/src/tools/cli-client/registry.ts b/packages/playwright-core/src/tools/cli-client/registry.ts index 67b58d3255728..76de68c4a601b 100644 --- a/packages/playwright-core/src/tools/cli-client/registry.ts +++ b/packages/playwright-core/src/tools/cli-client/registry.ts @@ -143,8 +143,8 @@ export class Registry { } export const baseDaemonDir = (() => { - if (process.env.PLAYWRIGHT_DAEMON_SESSION_DIR) - return process.env.PLAYWRIGHT_DAEMON_SESSION_DIR; + if (process.env.PWTEST_DAEMON_SESSION_DIR) + return process.env.PWTEST_DAEMON_SESSION_DIR; let localCacheDir: string | undefined; if (process.platform === 'linux') diff --git a/packages/utils/fileUtils.ts b/packages/utils/fileUtils.ts index 6c06f133a0847..327ac6e8028c3 100644 --- a/packages/utils/fileUtils.ts +++ b/packages/utils/fileUtils.ts @@ -120,16 +120,16 @@ const UNIX_SOCKET_PATH_MAX = 103; export function makeSocketPath(domain: string, name: string): string { const userNameHash = calculateSha1(process.env.USERNAME || process.env.USER || 'default').slice(0, 8); if (process.platform === 'win32') { - const socketsDir = process.env.PLAYWRIGHT_SOCKETS_DIR; + const socketsDir = process.env.PWTEST_SOCKETS_DIR; const suffix = socketsDir ? `-${calculateSha1(socketsDir).slice(0, 8)}` : ''; return `\\\\.\\pipe\\pw-${userNameHash}-${domain}-${name}${suffix}`; } - const baseDir = process.env.PLAYWRIGHT_SOCKETS_DIR || path.join(os.tmpdir(), `pw-${userNameHash}`); + const baseDir = process.env.PWTEST_SOCKETS_DIR || path.join(os.tmpdir(), `pw-${userNameHash}`); const dir = path.join(baseDir, domain); const suffix = '.sock'; const maxNameLength = UNIX_SOCKET_PATH_MAX - dir.length - path.sep.length - suffix.length; if (maxNameLength < 1) - throw new Error(`Socket directory path is too long (${dir.length} chars); set PLAYWRIGHT_SOCKETS_DIR to a shorter location.`); + throw new Error(`Socket directory path is too long (${dir.length} chars); set PWTEST_SOCKETS_DIR to a shorter location.`); const fsFriendlyName = trimLongString(sanitizeForFilePath(name), maxNameLength); const result = path.join(dir, `${fsFriendlyName}${suffix}`); fs.mkdirSync(dir, { recursive: true }); diff --git a/tests/extension/extension-fixtures.ts b/tests/extension/extension-fixtures.ts index b68c1d4b54588..c3c79abe54bbd 100644 --- a/tests/extension/extension-fixtures.ts +++ b/tests/extension/extension-fixtures.ts @@ -146,11 +146,11 @@ export const testWithOldExtensionVersion = test.extend({ function cliEnv() { return { - PLAYWRIGHT_SERVER_REGISTRY: test.info().outputPath('registry'), - PLAYWRIGHT_DAEMON_SESSION_DIR: test.info().outputPath('daemon'), + PWTEST_SERVER_REGISTRY: test.info().outputPath('registry'), + PWTEST_DAEMON_SESSION_DIR: test.info().outputPath('daemon'), // Short path because macOS caps unix socket paths at 104 chars; the // long `project.outputDir` path overflows and causes EADDRINUSE. - PLAYWRIGHT_SOCKETS_DIR: path.join(os.tmpdir(), 'pwmcp-sock', String(test.info().parallelIndex)), + PWTEST_SOCKETS_DIR: path.join(os.tmpdir(), 'pwmcp-sock', String(test.info().parallelIndex)), }; } diff --git a/tests/library/browser-server.spec.ts b/tests/library/browser-server.spec.ts index 27c62f946abf5..7d3890355c567 100644 --- a/tests/library/browser-server.spec.ts +++ b/tests/library/browser-server.spec.ts @@ -22,7 +22,7 @@ import { browserTest as it, expect } from '../config/browserTest'; it.skip(({ mode }) => mode !== 'default'); it.beforeEach(({}, testInfo) => { - process.env.PLAYWRIGHT_SERVER_REGISTRY = testInfo.outputPath('registry'); + process.env.PWTEST_SERVER_REGISTRY = testInfo.outputPath('registry'); }); it('should start and stop pipe server', async ({ browserType, browser }) => { diff --git a/tests/mcp/cli-fixtures.ts b/tests/mcp/cli-fixtures.ts index 32b960f73bbcf..11ef3ee974206 100644 --- a/tests/mcp/cli-fixtures.ts +++ b/tests/mcp/cli-fixtures.ts @@ -119,10 +119,10 @@ export const test = baseTest.extend<{ function cliEnv() { return { - PLAYWRIGHT_SERVER_REGISTRY: test.info().outputPath('registry'), + PWTEST_SERVER_REGISTRY: test.info().outputPath('registry'), PWTEST_DASHBOARD_SETTINGS_FILE: test.info().outputPath('dashboard.settings.json'), - PLAYWRIGHT_DAEMON_SESSION_DIR: test.info().outputPath('daemon'), - PLAYWRIGHT_SOCKETS_DIR: path.join(os.tmpdir(), 'ds-' + crypto.createHash('sha1').update(test.info().outputDir).digest('hex').slice(0, 16)), + PWTEST_DAEMON_SESSION_DIR: test.info().outputPath('daemon'), + PWTEST_SOCKETS_DIR: path.join(os.tmpdir(), 'ds-' + crypto.createHash('sha1').update(test.info().outputDir).digest('hex').slice(0, 16)), PWTEST_CLI_CHANNEL_SCAN_DISABLED_FOR_TEST: '1', }; } diff --git a/tests/mcp/cli-session.spec.ts b/tests/mcp/cli-session.spec.ts index 98dd2bec49edd..30bd50abd3eb3 100644 --- a/tests/mcp/cli-session.spec.ts +++ b/tests/mcp/cli-session.spec.ts @@ -268,7 +268,7 @@ test('older client with newer daemon - list shows incompatible warning', async ( test.describe('browser server', () => { test.beforeEach(async ({ mcpBrowser }, testInfo) => { test.skip(!['chrome', 'chromium', 'webkit', 'firefox'].includes(mcpBrowser)); - process.env.PLAYWRIGHT_SERVER_REGISTRY = testInfo.outputPath('registry'); + process.env.PWTEST_SERVER_REGISTRY = testInfo.outputPath('registry'); }); test('list browser servers', async ({ cli, mcpBrowser }) => { diff --git a/tests/mcp/dashboard.spec.ts b/tests/mcp/dashboard.spec.ts index f9a52b515f2b4..b10a2ec4dff01 100644 --- a/tests/mcp/dashboard.spec.ts +++ b/tests/mcp/dashboard.spec.ts @@ -30,7 +30,7 @@ function displayPath(p: string): string { } test.beforeEach(({}, testInfo) => { - process.env.PLAYWRIGHT_SERVER_REGISTRY = testInfo.outputPath('registry'); + process.env.PWTEST_SERVER_REGISTRY = testInfo.outputPath('registry'); }); test('should show browser session chip', async ({ cli, server, startDashboardServer }) => { From 40b41e200a239fc168461a1009d6d4887d5b316e Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Mon, 18 May 2026 13:13:47 -0700 Subject: [PATCH 3/3] test(library): update pipe-server endpoint expectation to sanitized form makeSocketPath now sanitizes the name, so the browser server's pipe endpoint contains `browser-` instead of `browser@`. --- tests/library/browser-server.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/library/browser-server.spec.ts b/tests/library/browser-server.spec.ts index 7d3890355c567..9f02faf996261 100644 --- a/tests/library/browser-server.spec.ts +++ b/tests/library/browser-server.spec.ts @@ -28,7 +28,7 @@ it.beforeEach(({}, testInfo) => { it('should start and stop pipe server', async ({ browserType, browser }) => { const serverInfo = await browser.bind('default', {}); expect(serverInfo).toEqual(expect.objectContaining({ - endpoint: expect.stringMatching(/browser@/), + endpoint: expect.stringMatching(/browser-/), })); const browser2 = await browserType.connect(serverInfo.endpoint);