diff --git a/src/main/daemon/daemon-endpoint-ownership.ts b/src/main/daemon/daemon-endpoint-ownership.ts index eb43a1fdd19c..b8396efd51ee 100644 --- a/src/main/daemon/daemon-endpoint-ownership.ts +++ b/src/main/daemon/daemon-endpoint-ownership.ts @@ -1,8 +1,6 @@ /* Who may serve on the daemon's canonical endpoint name: only a daemon publishing itself onto it, and only by replacing an entry it has itself just proven dead. Rationale and traps: AGENTS.md. */ -import { randomBytes } from 'node:crypto' import { linkSync, lstatSync, renameSync, statSync, unlinkSync } from 'node:fs' -import { dirname, join } from 'node:path' import { endpointIsProvenDead, type SocketProbeOutcome } from './daemon-endpoint-probe' // Retried only when another publisher demonstrably took the name, so a small bound suffices. @@ -11,15 +9,8 @@ const PUBLISH_ATTEMPTS = 3 /** A daemon endpoint, identified by its directory entry. dev+ino only; birth time is unreliable. */ export type DaemonSocketIdentity = { dev: bigint; ino: bigint } -/** - * A private, same-directory name to bind before publishing the canonical endpoint. - * - * `.p`, not the `.b` this used to be: released builds sweep that pattern on age alone. Replaces the - * basename rather than extending the path, so the ~104-byte `sockaddr_un.sun_path` budget holds. - */ -export function getDaemonSocketBindPath(socketPath: string): string { - return join(dirname(socketPath), `.p${randomBytes(5).toString('hex')}`) -} +/** Defined with the other endpoint names; the budget it protects is checked there too. */ +export { getDaemonSocketBindPath } from './daemon-runtime-paths' /** * Only `occupied` establishes that someone else owns the endpoint, and only then may the caller diff --git a/src/main/daemon/daemon-launch-paths.ts b/src/main/daemon/daemon-launch-paths.ts index 9b5b2c3dca84..c5eb3b2d169c 100644 --- a/src/main/daemon/daemon-launch-paths.ts +++ b/src/main/daemon/daemon-launch-paths.ts @@ -6,10 +6,13 @@ import { getDaemonLogFilePath } from '../observability/logs-directory' import { DaemonClient } from './client' import { daemonRecoveryProbeTimeoutMs } from './daemon-recovery-budget' import { remainingDaemonRequestTimeoutMs } from './daemon-request-deadline' +// From the definition, not daemon-spawner's re-export: many suites mock daemon-spawner, and +// reaching through it for a symbol they have no reason to stub breaks them. +import { getDaemonRuntimeDirPath } from './daemon-runtime-paths' import { PROTOCOL_VERSION, type ListSessionsResult } from './types' export function getDaemonRuntimeDir(): string { - const dir = join(getAppEnvironment().getPath('userData'), 'daemon') + const dir = getDaemonRuntimeDirPath(getAppEnvironment().getPath('userData')) mkdirSync(dir, { recursive: true }) return dir } diff --git a/src/main/daemon/daemon-runtime-paths.test.ts b/src/main/daemon/daemon-runtime-paths.test.ts new file mode 100644 index 000000000000..4b27ff00c01c --- /dev/null +++ b/src/main/daemon/daemon-runtime-paths.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest' +import { createServer } from 'node:net' +import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { + checkDaemonSocketPathBudget, + getDaemonRuntimeDirPath, + getDaemonSocketBindPath, + getDaemonSocketPath +} from './daemon-runtime-paths' +import { UNIX_SOCKET_PATH_LIMIT, unixSocketPathBytes } from '../../shared/unix-socket-path-budget' + +/** A data root whose daemon socket path lands on exactly `bytes`, or null if it cannot. */ +function rootForSocketBytes(base: string, bytes: number): string | null { + const sample = `${base}/r` + const overhead = + unixSocketPathBytes(getDaemonSocketPath(getDaemonRuntimeDirPath(sample))) - + unixSocketPathBytes(sample) + const rootBytes = bytes - overhead + const prefix = `${base}/` + if (rootBytes < unixSocketPathBytes(prefix)) { + return null + } + return prefix + 'x'.repeat(rootBytes - unixSocketPathBytes(prefix)) +} + +describe('daemon socket path budget', () => { + it('measures the canonical endpoint, which is the longest name bound', () => { + const root = '/home/orca/.orca' + const socketPath = getDaemonSocketPath(getDaemonRuntimeDirPath(root)) + const budget = checkDaemonSocketPathBudget(root) + expect(budget.longestPath).toBe(socketPath) + // The private bind name is shorter by construction; if it ever stops being, the budget + // follows it rather than silently under-measuring. + expect(unixSocketPathBytes(getDaemonSocketBindPath(socketPath))).toBeLessThan(budget.bytes) + }) + + it('fits an ordinary root and refuses a long one', () => { + expect(checkDaemonSocketPathBudget('/home/orca/.orca').fits).toBe(true) + expect(checkDaemonSocketPathBudget(`/home/${'x'.repeat(200)}/.orca`).fits).toBe(false) + }) + + it.skipIf(process.platform === 'win32')('flips at exactly the limit', () => { + const atLimit = rootForSocketBytes('/tmp', UNIX_SOCKET_PATH_LIMIT) + const overLimit = rootForSocketBytes('/tmp', UNIX_SOCKET_PATH_LIMIT + 1) + expect(atLimit).not.toBeNull() + expect(checkDaemonSocketPathBudget(atLimit as string).fits).toBe(true) + expect(checkDaemonSocketPathBudget(overLimit as string).fits).toBe(false) + }) +}) + +/** + * Ties the constant to the kernel. Without this the budget is a number in a file that nothing + * disagrees with, and the defect it exists to prevent — a daemon that cannot bind — is exactly + * what a wrong number reintroduces. + * + * The assertion is "a socket exists at the path", not an errno, because past the limit there is + * no single failure: on Linux a slightly-too-long path fails EADDRINUSE against a free name, and + * a longer one reports a successful listen with no directory entry at all. A stat-able entry is + * the property `publishDaemonEndpoint` is built on — it identifies the endpoint by dev+ino — so + * it is the property worth asserting. + */ +describe('the limit is the kernel’s', () => { + function bindable(socketPath: string): Promise { + mkdirSync(dirname(socketPath), { recursive: true }) + const server = createServer() + return new Promise((resolve) => { + server.once('error', () => resolve(false)) + server.listen(socketPath, () => resolve(existsSync(socketPath))) + }).finally(() => new Promise((done) => server.close(() => done(null)))) + } + + it.skipIf(process.platform === 'win32')( + 'binds at the limit and produces no endpoint past it', + async () => { + // A short base: the socket path has to be built to an exact length, and macOS tmpdir is + // already long enough to make that impossible. + const base = mkdtempSync(join(tmpdir(), 'sb-')) + try { + // Exactly the limit, because that is the length production admits: a constant one byte + // too high still binds at `limit - 1` and still fails past `limit + 1`, so only the + // accepted boundary catches it — and that is the direction that readmits the defect. + const atLimit = join(base, 'a'.repeat(UNIX_SOCKET_PATH_LIMIT - base.length - 1)) + const overLimit = `${atLimit}b` + expect(unixSocketPathBytes(atLimit)).toBe(UNIX_SOCKET_PATH_LIMIT) + expect(await bindable(atLimit)).toBe(true) + expect(unixSocketPathBytes(overLimit)).toBe(UNIX_SOCKET_PATH_LIMIT + 1) + expect(await bindable(overLimit)).toBe(false) + } finally { + rmSync(base, { recursive: true, force: true }) + } + } + ) +}) diff --git a/src/main/daemon/daemon-runtime-paths.ts b/src/main/daemon/daemon-runtime-paths.ts new file mode 100644 index 000000000000..12d929fcf763 --- /dev/null +++ b/src/main/daemon/daemon-runtime-paths.ts @@ -0,0 +1,99 @@ +/** + * Every path the daemon's endpoint is built from, and whether a data root leaves room for it. + * + * One leaf module, importing only node builtins and the protocol version, for two reasons: the + * names are a single source of truth shared by the spawner, the ownership protocol and orcad, + * and the doctor needs the arithmetic below without dragging the daemon into the CLI's graph. + */ +import { createHash, randomBytes } from 'node:crypto' +import { dirname, join } from 'node:path' +import { PROTOCOL_VERSION } from './daemon-protocol-version' +import { + measureUnixSocketPathBudget, + UNIX_SOCKET_PATH_LIMIT, + type UnixSocketPathBudget +} from '../../shared/unix-socket-path-budget' + +/** + * The daemon's runtime directory, as a path only. Separate from `getDaemonRuntimeDir`, which + * also creates it: the socket-path budget has to measure a root before anything touches it. + */ +export function getDaemonRuntimeDirPath(userDataPath: string): string { + return join(userDataPath, 'daemon') +} + +export function getDaemonSocketPath( + runtimeDir: string, + protocolVersion = PROTOCOL_VERSION +): string { + // Why: Windows IPC servers use named pipes rather than filesystem socket + // files. Include the protocol version in the endpoint name so a daemon from + // an older build is never reused after a breaking protocol change. + if (process.platform === 'win32') { + const suffix = createHash('sha256').update(runtimeDir).digest('hex').slice(0, 12) + return `\\\\?\\pipe\\orca-terminal-host-v${protocolVersion}-${suffix}` + } + return join(runtimeDir, `daemon-v${protocolVersion}.sock`) +} + +export function getDaemonTokenPath(runtimeDir: string, protocolVersion = PROTOCOL_VERSION): string { + return join(runtimeDir, `daemon-v${protocolVersion}.token`) +} + +export function getDaemonPidPath(runtimeDir: string, protocolVersion = PROTOCOL_VERSION): string { + return join(runtimeDir, `daemon-v${protocolVersion}.pid`) +} + +/** + * A private, same-directory name to bind before publishing the canonical endpoint. + * + * `.p`, not the `.b` this used to be: released builds sweep that pattern on age alone. Replaces + * the basename rather than extending the path, so the `sockaddr_un.sun_path` budget holds. + */ +export function getDaemonSocketBindPath(socketPath: string): string { + return join(dirname(socketPath), `.p${randomBytes(5).toString('hex')}`) +} + +/** + * Whether a data root leaves room for the daemon's socket to exist. + * + * Why a startup question and not a runtime one: the endpoint lives under the data root, so a + * long enough root pushes it past `sockaddr_un.sun_path` and the daemon dies before it serves + * anything. orcad's own listener spends a few bytes less, so there is a band of root lengths + * where orcad comes up, reports ready, and serves clients with terminal survival silently + * switched off — every terminal then dies on the next restart, which is the one thing forking + * a detached daemon exists to prevent. + * + * Pure arithmetic: no probe, no privileges, no filesystem. That is what lets the same check + * answer for a host the doctor is only reading a unit file about. + */ +export function checkDaemonSocketPathBudget(userDataPath: string): UnixSocketPathBudget { + const socketPath = getDaemonSocketPath(getDaemonRuntimeDirPath(userDataPath)) + if (process.platform === 'win32') { + // Named pipes are not filesystem paths and carry no sun_path budget. + return { longestPath: socketPath, bytes: 0, limit: UNIX_SOCKET_PATH_LIMIT, fits: true } + } + // Both names matter: the daemon binds the private one to listen, and every client connects to + // the canonical one. The bind name is random but fixed-width, so measuring one is measuring + // all of them — and measuring both means a change to either name cannot go unnoticed here. + return measureUnixSocketPathBudget([socketPath, getDaemonSocketBindPath(socketPath)]) +} + +/** One wording, so the startup refusal and the doctor finding cannot drift apart. */ +export function describeDaemonSocketPathOverflow(budget: UnixSocketPathBudget): string { + return ( + `The terminal daemon's socket path needs ${budget.bytes} bytes and the kernel accepts ` + + `${budget.limit} (${budget.longestPath}). The daemon cannot start, so terminals would ` + + 'not survive a restart.' + ) +} + +/** + * Names only mechanisms that exist. It previously offered `--user-data `, which orcad + * does not accept and — because the service commands ignored unrecognised flags — did not + * reject either: an operator following this advice got exit 0 and an `[OK] socket path fits` + * measured against the shell's own root, and would reasonably conclude it was fixed. + */ +export const DAEMON_SOCKET_PATH_REMEDY = + 'Move the data root to a shorter path: set ORCA_USER_DATA, and regenerate the service ' + + 'definition with it set if one is installed.' diff --git a/src/main/daemon/daemon-spawner.ts b/src/main/daemon/daemon-spawner.ts index a0376ef0fc0b..4c2fa27dd895 100644 --- a/src/main/daemon/daemon-spawner.ts +++ b/src/main/daemon/daemon-spawner.ts @@ -1,4 +1,4 @@ -import { createHash, randomUUID } from 'node:crypto' +import { randomUUID } from 'node:crypto' import { constants, copyFileSync, @@ -8,8 +8,15 @@ import { unlinkSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' -import { PROTOCOL_VERSION } from './types' +// Imported and re-exported rather than moved-and-rewritten: these are the same names every +// caller already imports from here, and they now have a single definition in the leaf module. +import { getDaemonPidPath, getDaemonSocketPath, getDaemonTokenPath } from './daemon-runtime-paths' +export { + getDaemonPidPath, + getDaemonRuntimeDirPath, + getDaemonSocketPath, + getDaemonTokenPath +} from './daemon-runtime-paths' import { DaemonCrashLoopError, DaemonRespawnThrottle } from './daemon-respawn-throttle' export type DaemonConnectionInfo = { @@ -119,28 +126,6 @@ export class DaemonSpawner { } } -export function getDaemonSocketPath( - runtimeDir: string, - protocolVersion = PROTOCOL_VERSION -): string { - // Why: Windows IPC servers use named pipes rather than filesystem socket - // files. Include the protocol version in the endpoint name so a daemon from - // an older build is never reused after a breaking protocol change. - if (process.platform === 'win32') { - const suffix = createHash('sha256').update(runtimeDir).digest('hex').slice(0, 12) - return `\\\\?\\pipe\\orca-terminal-host-v${protocolVersion}-${suffix}` - } - return join(runtimeDir, `daemon-v${protocolVersion}.sock`) -} - -export function getDaemonTokenPath(runtimeDir: string, protocolVersion = PROTOCOL_VERSION): string { - return join(runtimeDir, `daemon-v${protocolVersion}.token`) -} - -export function getDaemonPidPath(runtimeDir: string, protocolVersion = PROTOCOL_VERSION): string { - return join(runtimeDir, `daemon-v${protocolVersion}.pid`) -} - export function serializeDaemonPidFile(pidFile: DaemonPidFile): string { return JSON.stringify(pidFile) } diff --git a/src/main/orcad/orcad-daemon-socket-preflight.test.ts b/src/main/orcad/orcad-daemon-socket-preflight.test.ts new file mode 100644 index 000000000000..8ad262dfc12b --- /dev/null +++ b/src/main/orcad/orcad-daemon-socket-preflight.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { + assertDaemonSocketPathFits, + OrcadDaemonSocketPathError +} from './orcad-daemon-socket-preflight' +import { ORCAD_EXIT_CONFIGURATION, resolveOrcadExitCode } from './orcad-entry' + +describe('daemon socket path preflight', () => { + it('accepts an ordinary root', () => { + expect(() => assertDaemonSocketPathFits('/home/orca/.orca')).not.toThrow() + }) + + it.skipIf(process.platform === 'win32')('refuses a root that cannot host the socket', () => { + expect(() => assertDaemonSocketPathFits(`/home/${'x'.repeat(120)}/.orca`)).toThrow( + OrcadDaemonSocketPathError + ) + }) + + it('exits 78 so a supervisor stops instead of restart-spinning', () => { + // The whole point of refusing here rather than degrading: the generated unit sets + // RestartPreventExitStatus=78, and no restart will ever shorten the path. + expect(resolveOrcadExitCode(new OrcadDaemonSocketPathError('too long'))).toBe( + ORCAD_EXIT_CONFIGURATION + ) + }) +}) diff --git a/src/main/orcad/orcad-daemon-socket-preflight.ts b/src/main/orcad/orcad-daemon-socket-preflight.ts new file mode 100644 index 000000000000..4b66f9097f2b --- /dev/null +++ b/src/main/orcad/orcad-daemon-socket-preflight.ts @@ -0,0 +1,37 @@ +/** + * Refuses a data root whose daemon socket path cannot fit in `sockaddr_un.sun_path`. + * + * Why this refuses where a failed daemon start does not: `startOrcadDaemon` is deliberately + * fail-open, because the reasons a daemon fails at runtime are unenumerable and often + * transient, and a host that cannot fork one can still serve git and worktrees. This failure + * is none of those things — it is known before anything is attempted, it is permanent until + * an operator changes a value they supplied, and it is arithmetic. That puts it with the bind + * address and the instance lock, which already refuse, rather than with the runtime failures. + * + * The alternative is what the field found: orcad's own listener is a few bytes shorter than + * the daemon's, so a root in that band comes up, reports ready, and serves normally with + * survival off. The warning it prints goes to journald, and a host with volatile journald + * loses it on the reboot that also destroys every terminal. + * + * Its own module, and importing only path arithmetic, so `orcad-entry` can call it without + * pulling the daemon (and node-pty behind it) into the static graph the native preflight + * has to run ahead of. + */ +import { + checkDaemonSocketPathBudget, + DAEMON_SOCKET_PATH_REMEDY, + describeDaemonSocketPathOverflow +} from '../daemon/daemon-runtime-paths' + +export class OrcadDaemonSocketPathError extends Error { + readonly code = 'orcad_daemon_socket_path_too_long' +} + +export function assertDaemonSocketPathFits(userDataPath: string): void { + const budget = checkDaemonSocketPathBudget(userDataPath) + if (!budget.fits) { + throw new OrcadDaemonSocketPathError( + `${describeDaemonSocketPathOverflow(budget)} ${DAEMON_SOCKET_PATH_REMEDY}` + ) + } +} diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 3b10ca985e9a..936796417c3a 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -28,6 +28,10 @@ import { OrcadInstanceLockError, type OrcadInstanceLock } from './orcad-instance-lock' +import { + assertDaemonSocketPathFits, + OrcadDaemonSocketPathError +} from './orcad-daemon-socket-preflight' let runOrcadQuitHandlers = (): void => {} @@ -107,6 +111,9 @@ export type OrcadHandle = { export async function startOrcad(options: OrcadOptions = {}): Promise { installOrcadHostAdapters() const userDataPath = resolveUserDataPath() + // Why ahead of the lock: this reads nothing and creates nothing, and refusing a root that + // cannot host a daemon socket should not first take a lock on it. + assertDaemonSocketPathFits(userDataPath) // Why before anything else touches the root: the profile index, the store and the daemon // runtime dir all live under it, and two orcads sharing them corrupt state silently. This // is also the last point at which refusing costs nothing. @@ -318,10 +325,10 @@ export function parseArgs(argv: string[]): OrcadOptions { /** * Exit codes a supervisor can act on. Closed set — see docs/reference/orcad-operations.md. * - * `ORCAD_EXIT_CONFIGURATION` is the load-bearing one: a data root owned by someone else, or - * held by another orcad, is not fixed by restarting. Restarting on it is the crash-loop the - * supervision contract has to prevent, so systemd's `RestartPreventExitStatus` needs a code - * that means "do not retry" and nothing else does. + * `ORCAD_EXIT_CONFIGURATION` is the load-bearing one: a data root owned by someone else, held + * by another orcad, or too long to hold a daemon socket, is not fixed by restarting. + * Restarting on it is the crash-loop the supervision contract has to prevent, so systemd's + * `RestartPreventExitStatus` needs a code that means "do not retry" and nothing else does. */ export const ORCAD_EXIT_OK = 0 export const ORCAD_EXIT_FAILED = 1 @@ -331,7 +338,9 @@ export const ORCAD_EXIT_CONFIGURATION = 78 export const ORCAD_SHUTDOWN_DEADLINE_MS = 15_000 export function resolveOrcadExitCode(error: unknown): number { - return error instanceof OrcadInstanceLockError || error instanceof OrcadBindAddressError + return error instanceof OrcadInstanceLockError || + error instanceof OrcadBindAddressError || + error instanceof OrcadDaemonSocketPathError ? ORCAD_EXIT_CONFIGURATION : ORCAD_EXIT_FAILED } diff --git a/src/main/ssh/ssh-control-socket.ts b/src/main/ssh/ssh-control-socket.ts index 73622ba504e0..9c1d2fcac9ad 100644 --- a/src/main/ssh/ssh-control-socket.ts +++ b/src/main/ssh/ssh-control-socket.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os' import { isAbsolute, join as pathJoin } from 'node:path' import type { SshTarget } from '../../shared/ssh-types' import type { SshResolvedConfig } from './ssh-config-parser' +import { UNIX_SOCKET_PATH_LIMIT, unixSocketPathBytes } from '../../shared/unix-socket-path-budget' export type SystemSshResolvedConfig = Pick< SshResolvedConfig, @@ -23,7 +24,6 @@ export type SystemSshResolvedConfig = Pick< > const OPENSSH_CONTROL_SOCKET_SUFFIX_BUDGET = 18 -const UNIX_SOCKET_PATH_LIMIT = process.platform === 'darwin' ? 104 : 108 const CONTROL_SOCKET_PATH_MAX_LENGTH = UNIX_SOCKET_PATH_LIMIT - OPENSSH_CONTROL_SOCKET_SUFFIX_BUDGET export function getControlSocketPath( @@ -65,7 +65,8 @@ export function getControlSocketPath( }) const hash = createHash('sha256').update(key).digest('hex').slice(0, 16) const socketPath = pathJoin(dir, hash) - return socketPath.length <= CONTROL_SOCKET_PATH_MAX_LENGTH ? socketPath : null + // Bytes, not characters: OpenSSH spends the same sun_path budget the kernel measures. + return unixSocketPathBytes(socketPath) <= CONTROL_SOCKET_PATH_MAX_LENGTH ? socketPath : null } export function removeControlSocketPath(socketPath: string): void { diff --git a/src/shared/unix-socket-path-budget.test.ts b/src/shared/unix-socket-path-budget.test.ts new file mode 100644 index 000000000000..550a21b6912e --- /dev/null +++ b/src/shared/unix-socket-path-budget.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { + measureUnixSocketPathBudget, + UNIX_SOCKET_PATH_LIMIT, + unixSocketPathBytes +} from './unix-socket-path-budget' + +describe('unix socket path bytes', () => { + it('counts bytes, not characters', () => { + // A data root with an accented component spends more budget than its length suggests, + // and a character count would report a path that does not fit as fitting. + expect('/home/josé/.orca'.length).toBe(16) + expect(unixSocketPathBytes('/home/josé/.orca')).toBe(17) + }) + + it('agrees with length for ASCII', () => { + expect(unixSocketPathBytes('/tmp/x')).toBe(6) + }) +}) + +describe('budget measurement', () => { + it('reports the longest candidate, not the first', () => { + const budget = measureUnixSocketPathBudget(['/tmp/short', '/tmp/a-much-longer-name']) + expect(budget.longestPath).toBe('/tmp/a-much-longer-name') + expect(budget.bytes).toBe('/tmp/a-much-longer-name'.length) + }) + + it('fits exactly at the limit and not one byte past it', () => { + const exact = `/${'x'.repeat(UNIX_SOCKET_PATH_LIMIT - 1)}` + expect(unixSocketPathBytes(exact)).toBe(UNIX_SOCKET_PATH_LIMIT) + expect(measureUnixSocketPathBudget([exact]).fits).toBe(true) + expect(measureUnixSocketPathBudget([`${exact}x`]).fits).toBe(false) + }) +}) diff --git a/src/shared/unix-socket-path-budget.ts b/src/shared/unix-socket-path-budget.ts new file mode 100644 index 000000000000..8885064c2895 --- /dev/null +++ b/src/shared/unix-socket-path-budget.ts @@ -0,0 +1,48 @@ +/** + * How many bytes of a unix socket path the kernel will accept. + * + * `sockaddr_un.sun_path` is a fixed-size byte array rather than a pointer, so this is a hard + * kernel limit with nothing to do with PATH_MAX. Bytes and not characters: a data root with + * a non-ASCII component spends more of the budget than its length suggests. + * + * Refusing up front rather than letting `bind` report the problem, because past the limit + * there is no single failure to report. Measured on Linux (node 22 / libuv 1.51), binding a + * path of: + * - 109-110 bytes fails EADDRINUSE against an empty directory, naming a free path; + * - 111+ bytes SUCCEEDS, and connect() to it succeeds, but no directory entry is ever + * created — so it reads as a healthy daemon to everything except code that stats the + * entry, which is what daemon endpoint ownership is built out of. + * Only the arithmetic is the same on every host, so only the arithmetic is worth trusting. + */ +export const UNIX_SOCKET_PATH_LIMIT = process.platform === 'darwin' ? 104 : 108 + +/** TextEncoder rather than Buffer: this module is shared, and Buffer is not universal. */ +export function unixSocketPathBytes(socketPath: string): number { + return new TextEncoder().encode(socketPath).length +} + +export type UnixSocketPathBudget = { + /** The longest of the candidates — the one that decides whether any of them work. */ + longestPath: string + bytes: number + limit: number + fits: boolean +} + +/** + * Measures the candidates a component must bind or connect to, and reports the longest. + * Longest rather than one nominated path: which name is longest is a property of how they + * are built, and a caller that hardcodes the answer stops being right when a name changes. + */ +export function measureUnixSocketPathBudget(candidates: string[]): UnixSocketPathBudget { + const longestPath = candidates.reduce((a, b) => + unixSocketPathBytes(b) > unixSocketPathBytes(a) ? b : a + ) + const bytes = unixSocketPathBytes(longestPath) + return { + longestPath, + bytes, + limit: UNIX_SOCKET_PATH_LIMIT, + fits: bytes <= UNIX_SOCKET_PATH_LIMIT + } +}