From c534e249d1838fdb6328e133fa109cc6684cc6da Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Sat, 31 Oct 2026 11:12:02 -0700 Subject: [PATCH] perf(process): reuse a verified Darwin ownership helper across processes (#8318) * perf(process): reuse a verified Darwin ownership helper across processes Every REA process on macOS compiled the Swift ownership helper into a fresh temporary root and then paid macOS's first-launch assessment of that new executable: 408 ms + 325 ms of a 1.4 s `rea inspect-macho /bin/ls`. With the default xcrun compiler and module cache, the reader now keeps one content-addressed copy under ~/Library/Caches/rea/process-helper//. The key covers the helper sources, toolchain selection, architecture and Darwin release. A copy is executed only after the cache root, entry and files are verified as current-user owned, not group- or world-writable, not links, and digest-matched to the entry manifest. Malformed entries owned by the user are removed and rebuilt; linked or foreign entries are left untouched and the per-process compile runs as before. Any cache failure falls back to that compile, so the cache never fails an ownership operation. A miss compiles exactly as before, then publishes a copy through an atomic rename, keeping the first concurrent publisher, and runs the shared copy. That way its first-launch assessment is paid once. Per-run roots keep their existing cleanup; published entries are never removed by close or exit. An explicit REA_DARWIN_SWIFTC_PATH or REA_DARWIN_MODULE_CACHE_PATH keeps the per-process compile and writes nothing to the cache. `rea uninstall --purge-data` removes the namespace. Fixes #2483 Co-Authored-By: Claude Opus 5.5 * fix(process): harden the shared Darwin helper cache Address review findings on the shared ownership-helper cache: - Verify every real parent of the cache root: each must belong to the user or root and not be writable by others (sticky root-owned directories excepted), so no other user can rename a verified entry. Execute through the real path. - Open manifest and helper through the owned non-blocking regular-file reader, so a FIFO cannot block preparation, and retain handles whose close fails. - Key entries on the swiftc and SDK that xcrun itself resolves, including their real paths, sizes and modification times. DEVELOPER_DIR, TOOLCHAINS, xcode-select and in-place updates therefore select new entries. - Never remove or replace an invalid or published entry. Removal could race another process that has verified or is running that path, so such an entry falls back to the per-process compile. - Give the cache a close() that retries retained staging removals and handle closes and reports ProviderCleanupError while any remain. The reader closes it with its per-run root. Co-Authored-By: Claude Opus 5.5 * fix(process): keep cache lookups owned and fall back from unlaunchable helpers Address the second round of review on the shared ownership-helper cache: - Pass the preparation signal to both xcrun toolchain lookups, and wait for every started lookup before reporting the first failure. Cancellation and close then stop a stalled lookup instead of waiting out its deadline. A failed or cancelled key computation is retried by the next lookup. - When the verified shared copy cannot launch (EACCES, EPERM, ENOEXEC, ENOENT or an unrequested SIGKILL, for example from a noexec home volume), the reader stops using the cache. It reuses its own compiled helper or compiles one, and retries the read once. - Anchor the cache's returned cleanup errors as cleanupRoot does, so every error returned while cleanup was pending resolves after a retry succeeds. - Build the cache path with appendCallerPath, so a home with symlinks and `..` keeps the caller's meaning instead of being normalized lexically. Co-Authored-By: Claude Opus 5.5 * fix(process): keep concurrent fallbacks and dual cleanup failures Each helper read now records before awaiting whether it launched the shared copy, so a concurrent read's fallback cannot hide its own. When the per-run root and the cache's retained resources both fail one close, the returned error composes both. It stays incomplete until both owners recover. Co-Authored-By: Claude Opus 5.5 * fix(process): refuse shared helpers that an ACL lets others write macOS ACLs can grant writes that the POSIX mode bits do not show. On Darwin, lookup and publication list the real ancestors, the entry and its files with `ls -lde`, and refuse sharing when any ACL entry allows a write-class right. The default `group:everyone deny delete` entries on macOS home folders are unaffected. The unlaunchable-helper test now removes the execute bit from a verified copy, which refuses launch on both Linux and macOS. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- docs/process-capture.md | 27 + src/application/Uninstall.ts | 4 + src/process/DarwinHelperCache.ts | 465 ++++++++++++++++ src/process/DarwinProcessRunTokenReader.ts | 94 +++- src/process/ProcessOwnershipObservation.ts | 9 + .../filesystem/managedDataPurge.test.ts | 16 + .../process/darwinHelperCache.test.ts | 527 ++++++++++++++++++ tests/fixtures/darwinHelperToolchain.ts | 61 ++ .../darwinHelperCacheCleanup.test.ts | 130 +++++ 9 files changed, 1328 insertions(+), 5 deletions(-) create mode 100644 src/process/DarwinHelperCache.ts create mode 100644 tests/boundary/process/darwinHelperCache.test.ts create mode 100644 tests/fixtures/darwinHelperToolchain.ts create mode 100644 tests/process-global/darwinHelperCacheCleanup.test.ts diff --git a/docs/process-capture.md b/docs/process-capture.md index e4b10435be..885606daa8 100644 --- a/docs/process-capture.md +++ b/docs/process-capture.md @@ -652,6 +652,33 @@ that directory: it outlives each run, so the first compilation into a new directory pays a cold-cache build and later runs reuse it. An absent or empty value keeps the shared default. +With the default `xcrun` compiler and module cache, REA also keeps one verified +copy of the compiled helper in `~/Library/Caches/rea/process-helper/`, so later +REA processes skip both the compile and macOS's first-launch assessment of a +new executable. Each entry is keyed by the helper source, the `swiftc` and SDK +that `xcrun --find swiftc` and `xcrun --show-sdk-path` resolve (their real +paths, sizes and modification times, so `DEVELOPER_DIR`, `TOOLCHAINS`, +`xcode-select` and in-place toolchain updates all select a new entry), the CPU +architecture and the Darwin release. Before running an entry, REA requires the +cache directory, the entry and its files to be owned by the current user and +not writable by group or others, every real parent directory to be owned by +the current user or root and not writable by others (sticky root-owned +directories excepted), no ACL on any of them to allow another principal a +write-class right (the default `deny delete` entries are fine), the helper and +manifest to be regular files rather than +links or FIFOs, and the helper's SHA-256 to match the manifest. Anything else is +never executed and never removed, because another REA process may still be +verifying or running that path; REA then compiles a per-process helper as +before. A published entry is never replaced. If the cache is unavailable, for +example under a read-only home directory, the ownership operation proceeds +with the per-process compile. These checks reject corruption, loosened +permissions and other users, but they do not defend against another process +running as the same user, which could already modify REA itself. Setting +`REA_DARWIN_SWIFTC_PATH` or `REA_DARWIN_MODULE_CACHE_PATH` keeps the per-process +compile and writes nothing to this cache. REA does not prune old entries; each +is about 240 KiB. Delete the directory while no REA process is running, or run +`rea uninstall --purge-data`, to remove them. + `xcrun` also maintains a separate tool-lookup cache. Its `--no-cache` option refreshes entries and does not guarantee a write-free lookup. To avoid that lookup while compiling REA's helper, set `REA_DARWIN_SWIFTC_PATH` to the absolute diff --git a/src/application/Uninstall.ts b/src/application/Uninstall.ts index 8074067e57..43c2196fd4 100644 --- a/src/application/Uninstall.ts +++ b/src/application/Uninstall.ts @@ -25,6 +25,7 @@ import { defaultKunaCacheDirectory, sameLexicalDirectory, } from "../config/kunaCacheDirectory.js"; +import { defaultDarwinHelperCacheDirectory } from "../process/DarwinHelperCache.js"; import { linuxHopperReleaseCacheDirectory } from "./LinuxHopperCacheDirectory.js"; import { ClientConfigurationPublication } from "./ClientConfigurationPublication.js"; @@ -1163,6 +1164,9 @@ const purgeSelectedData = async ( ), ); } + // The ownership helper cache has no caller-selected location to retain. + if (platform === "darwin") + directories.push(defaultDarwinHelperCacheDirectory(home)); if (platform === "linux") { const hopper = linuxHopperReleaseCacheDirectory(environment, home); if (hopper.ok) directories.push(hopper.value); diff --git a/src/process/DarwinHelperCache.ts b/src/process/DarwinHelperCache.ts new file mode 100644 index 0000000000..61ac6e0150 --- /dev/null +++ b/src/process/DarwinHelperCache.ts @@ -0,0 +1,465 @@ +import { createHash } from "node:crypto"; +import { constants, type Stats } from "node:fs"; +import { + chmod, + copyFile, + lstat, + mkdir, + mkdtemp, + readFile, + realpath, + rename, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { release } from "node:os"; +import { dirname, isAbsolute, join } from "node:path"; +import { z } from "zod"; + +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { safeParseJson } from "../domain/safeJson.js"; +import { appendCallerPath } from "../filesystem/callerPath.js"; +import type { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; +import { withOwnedRegularFile } from "../filesystem/OwnedRegularFile.js"; +import { readStableFileBytes } from "../filesystem/readStableFileBytes.js"; +import { execFileOutput } from "./ExecFileOutput.js"; + +/** Bumped when the entry layout or the key material changes meaning. */ +const CACHE_FORMAT = 2; +const HELPER_NAME = "reader"; +const MANIFEST_NAME = "manifest.json"; +const MAX_MANIFEST_BYTES = 4096; +// The compiled helper is about 240 KiB; refuse to hash anything far larger. +const MAX_HELPER_BYTES = 16 * 1024 * 1024; +// xcrun answers a lookup in milliseconds; a stalled one only forgoes reuse. +const TOOLCHAIN_LOOKUP_TIMEOUT_MS = 15_000; + +const manifestSchema = z.strictObject({ + format: z.literal(CACHE_FORMAT), + key: z.string().regex(/^[a-f0-9]{64}$/u), + sha256: z.string().regex(/^[a-f0-9]{64}$/u), + size: z.int().positive(), +}); + +// Sticky directories let only an entry's owner rename or remove it. +const STICKY = 0o1000; + +const sha256 = (value: string | Uint8Array): string => + createHash("sha256").update(value).digest("hex"); + +/** Per-user namespace REA owns for the compiled Darwin ownership helper. */ +export const defaultDarwinHelperCacheDirectory = (home: string): string => + appendCallerPath(home, "Library/Caches/rea/process-helper", "darwin"); + +/** + * Only the default toolchain shares a helper: a caller-selected compiler or + * module cache keeps every helper write inside the boundary the caller chose. + */ +export const darwinHelperCacheDirectory = (selection: { + readonly home: string | undefined; + readonly compilerSelected: boolean; + readonly moduleCachePath: string | undefined; +}): string | undefined => + selection.compilerSelected || + (selection.moduleCachePath ?? "") !== "" || + selection.home === undefined || + !isAbsolute(selection.home) + ? undefined + : defaultDarwinHelperCacheDirectory(selection.home); + +/** Only the current user may have written an entry REA will execute. */ +const privatelyOwned = (stats: Stats, uid: number): boolean => + stats.uid === uid && (stats.mode & 0o022) === 0; + +/** + * No other user can rename an entry on the path: each real ancestor belongs + * to this user or root and is not writable by others, unless it is a sticky + * root-owned directory, where only an entry's owner may rename it. + */ +const ancestorsTrusted = async ( + path: string, + uid: number, +): Promise => { + for (let current = path; ; current = dirname(current)) { + const stats = await lstat(current); + if (!stats.isDirectory() || (stats.uid !== uid && stats.uid !== 0)) + return false; + if ( + (stats.mode & 0o022) !== 0 && + !(stats.uid === 0 && (stats.mode & STICKY) !== 0) + ) + return false; + if (dirname(current) === current) return true; + } +}; + +/** Every real directory from the cache root up to the filesystem root. */ +const realAncestors = (path: string): string[] => { + const paths = [path]; + for (let current = path; dirname(current) !== current;) { + current = dirname(current); + paths.push(current); + } + return paths; +}; + +// Rights an ACL can grant beyond the POSIX mode bits that permit replacing an +// entry or its contents. +const ACL_WRITE_RIGHTS: ReadonlySet = new Set([ + "write", + "append", + "writeattr", + "writeextattr", + "writesecurity", + "chown", + "delete", + "add_file", + "add_subdirectory", + "delete_child", +]); + +/** + * `ls -le` lists each ACL entry as `N: principal [inherited] allow|deny + * rights`. macOS homes carry `deny delete` entries by default; only an + * `allow` of a write-class right lets someone the mode bits exclude write. + */ +export const aclListingGrantsWrite = (listing: string): boolean => + listing.split("\n").some((line) => { + const entry = + /^\s*\d+:\s+\S+\s+(?:inherited\s+)?(allow|deny)\s+(\S+)/u.exec(line); + return ( + entry?.[1] === "allow" && + (entry[2] ?? "").split(",").some((right) => ACL_WRITE_RIGHTS.has(right)) + ); + }); + +const isMissing = (cause: unknown): boolean => + cause instanceof Error && "code" in cause && cause.code === "ENOENT"; + +/** Identity of a file whose bytes shape the compiled helper. */ +const fileIdentity = async (path: string) => { + const real = await realpath(path); + const facts = await stat(real); + return { path: real, size: facts.size, mtime_ms: facts.mtimeMs }; +}; + +/** Result of looking up the helper for this exact source and toolchain. */ +export type DarwinHelperLookup = + | { readonly kind: "hit"; readonly executable: string } + | { readonly kind: "miss"; readonly key: string } + | { readonly kind: "unavailable" }; + +/** + * Content-addressed reuse of the compiled ownership helper across REA + * processes. Every failure falls back to the per-process compile: the cache + * never fails an ownership operation and never executes an entry that is not + * a private, current-user, digest-verified copy for this key. A published + * entry is never replaced, so a running reader's helper path stays valid. + * Same-user tampering is outside this boundary, as it is for the per-process + * helper. + */ +export const createDarwinHelperCache = (options: { + readonly directory: string; + readonly sources: readonly string[]; + /** The xcrun that resolves the toolchain the compile will use. */ + readonly xcrun: string; + readonly environment: Readonly; +}) => { + const uid = process.getuid?.(); + // Failed closes and removals stay owned until close() retries them. + const retained = new Map Promise>(); + // The first unresolved close failure; later ones depend on it. + let cleanupAnchor: ProviderCleanupError | undefined; + let key: Promise | undefined; + + const xcrun = async ( + arguments_: readonly string[], + signal: AbortSignal | undefined, + ): Promise => { + const { stdout } = await execFileOutput(options.xcrun, arguments_, { + env: options.environment, + timeout: TOOLCHAIN_LOOKUP_TIMEOUT_MS, + maxBuffer: 64 * 1024, + stopSignal: "SIGTERM", + ...(signal === undefined ? {} : { signal }), + }); + const path = stdout.trim(); + if (!isAbsolute(path)) + throw new Error(`xcrun ${arguments_.join(" ")} returned no path`); + return path; + }; + + /** Wait for every started read before reporting the first failure. */ + const settleAll = async (pending: { + readonly [Index in keyof Values]: Promise; + }): Promise => { + await Promise.allSettled(pending); + return Promise.all(pending) as Promise; + }; + + /** xcrun owns toolchain selection; key on what it resolves. */ + const computeKey = async (signal: AbortSignal | undefined) => { + const [sources, compilerPath, sdkPath] = await settleAll< + [string[], string, string] + >([ + Promise.all( + options.sources.map(async (source) => sha256(await readFile(source))), + ), + xcrun(["--find", "swiftc"], signal), + xcrun(["--show-sdk-path"], signal), + ]); + const [compiler, sdk] = await settleAll< + [ + Awaited>, + Awaited>, + ] + >([ + fileIdentity(compilerPath), + fileIdentity(join(sdkPath, "SDKSettings.json")), + ]); + return sha256( + JSON.stringify({ + format: CACHE_FORMAT, + sources, + compiler, + sdk, + architecture: process.arch, + os_release: release(), + }), + ); + }; + + /** A cancelled or failed key computation is retried by the next lookup. */ + const cacheKey = (signal: AbortSignal | undefined): Promise => { + if (key !== undefined) return key; + const pending = computeKey(signal); + key = pending; + pending.catch(() => { + if (key === pending) key = undefined; + }); + return pending; + }; + + /** On macOS, no ACL may grant a write that the mode bits withhold. */ + const withoutAclWriteGrants = async ( + paths: readonly string[], + signal: AbortSignal | undefined, + ): Promise => { + if (process.platform !== "darwin") return true; + const { stdout } = await execFileOutput( + "/bin/ls", + ["-lde", "--", ...paths], + { + env: options.environment, + timeout: TOOLCHAIN_LOOKUP_TIMEOUT_MS, + maxBuffer: 1024 * 1024, + stopSignal: "SIGTERM", + ...(signal === undefined ? {} : { signal }), + }, + ); + return !aclListingGrantsWrite(stdout); + }; + + /** The real cache root, created privately, with only trusted ancestors. */ + const trustedRoot = async (owner: number): Promise => { + await mkdir(options.directory, { recursive: true, mode: 0o700 }); + const named = await lstat(options.directory); + if (!named.isDirectory() || !privatelyOwned(named, owner)) return undefined; + const root = await realpath(options.directory); + return (await ancestorsTrusted(root, owner)) ? root : undefined; + }; + + const retainClose = (owner: OwnedFileHandle): void => { + retained.set(`file handle ${String(owner.handle.fd)}`, () => owner.close()); + }; + + /** Hash the opened descriptor itself; FIFOs and links never block or pass. */ + const readEntryFile = ( + path: string, + owner: number, + maximumBytes: number, + executable: boolean, + ): Promise => + withOwnedRegularFile( + path, + { symlinks: "reject", retainFailedClose: retainClose }, + async ({ handle, initial }) => + !privatelyOwned(initial, owner) || + (executable && (initial.mode & 0o100) === 0) || + initial.size === 0 || + initial.size > maximumBytes + ? undefined + : readStableFileBytes(handle, path, maximumBytes, { + symlinks: "reject", + }), + ).catch(() => undefined); + + const verifiedHelper = async ( + entry: string, + entryKey: string, + owner: number, + ): Promise => { + const manifestBytes = await readEntryFile( + join(entry, MANIFEST_NAME), + owner, + MAX_MANIFEST_BYTES, + false, + ); + const decoded = + manifestBytes === undefined + ? undefined + : safeParseJson(manifestBytes.toString("utf8")); + const manifest = + decoded?.ok === true + ? manifestSchema.safeParse(decoded.value) + : undefined; + if (manifest?.success !== true || manifest.data.key !== entryKey) + return undefined; + const executable = join(entry, HELPER_NAME); + const helper = await readEntryFile( + executable, + owner, + MAX_HELPER_BYTES, + true, + ); + return helper !== undefined && + helper.byteLength === manifest.data.size && + sha256(helper) === manifest.data.sha256 + ? executable + : undefined; + }; + + const lookup = async (signal?: AbortSignal): Promise => { + if (uid === undefined) return { kind: "unavailable" }; + try { + const root = await trustedRoot(uid); + if (root === undefined) return { kind: "unavailable" }; + const entryKey = await cacheKey(signal); + const entry = join(root, entryKey); + let entryStats: Stats; + try { + entryStats = await lstat(entry); + } catch (cause: unknown) { + if (isMissing(cause)) return { kind: "miss", key: entryKey }; + throw cause; + } + // An invalid entry is left in place: removing it could race another + // process that has just verified or published that path. + if (!entryStats.isDirectory() || !privatelyOwned(entryStats, uid)) + return { kind: "unavailable" }; + const executable = await verifiedHelper(entry, entryKey, uid); + if ( + executable === undefined || + !(await withoutAclWriteGrants( + [ + ...realAncestors(root), + entry, + executable, + join(entry, MANIFEST_NAME), + ], + signal, + )) + ) + return { kind: "unavailable" }; + return { kind: "hit", executable }; + } catch { + return { kind: "unavailable" }; + } + }; + + const removeStaging = async (path: string): Promise => { + const remove = () => rm(path, { recursive: true, force: true }); + await remove().catch(() => { + retained.set(path, remove); + }); + }; + + /** + * Atomically publish a verified copy and return the shared helper to run. + * macOS assesses each new executable file on its first launch (about 0.3 s), + * so running the shared copy pays that once for every later process. + */ + const publish = async ( + entryKey: string, + compiled: string, + signal?: AbortSignal, + ): Promise => { + if (uid === undefined) return undefined; + let staging: string | undefined; + try { + const root = await trustedRoot(uid); + if ( + root === undefined || + !(await withoutAclWriteGrants(realAncestors(root), signal)) + ) + return undefined; + staging = await mkdtemp(join(root, ".publish-")); + const copied = join(staging, HELPER_NAME); + await copyFile(compiled, copied, constants.COPYFILE_EXCL); + await chmod(copied, 0o700); + const helper = await readEntryFile(copied, uid, MAX_HELPER_BYTES, true); + if (helper === undefined) return undefined; + await writeFile( + join(staging, MANIFEST_NAME), + JSON.stringify({ + format: CACHE_FORMAT, + key: entryKey, + sha256: sha256(helper), + size: helper.byteLength, + }), + { mode: 0o600, flag: "wx" }, + ); + const entry = join(root, entryKey); + try { + await rename(staging, entry); + staging = undefined; + return join(entry, HELPER_NAME); + } catch { + // A concurrent publisher won; run its entry only once it verifies. + const winner = await lookup(signal); + return winner.kind === "hit" ? winner.executable : undefined; + } + } catch { + // Publication is an optimization; the caller keeps its own helper. + return undefined; + } finally { + if (staging !== undefined) await removeStaging(staging); + } + }; + + /** Retry every retained close or removal; unresolved ones stay owned. */ + const close = async (): Promise => { + const failed: string[] = []; + // Deleting the visited entry while iterating a Map is well defined. + for (const [resource, cleanup] of retained) { + try { + await cleanup(); + retained.delete(resource); + } catch { + failed.push(resource); + } + } + if (failed.length > 0) { + const failure = new ProviderCleanupError( + "process-ownership", + failed, + { + reason: + "Darwin ownership-helper cache resources remain owned until cleanup succeeds.", + }, + { operation: "prepare_owned_process" }, + ); + // Every returned failure clears once a later retry releases them all. + if (cleanupAnchor === undefined) cleanupAnchor = failure; + else failure.bindCleanupDependencies([cleanupAnchor]); + throw failure; + } + cleanupAnchor?.resolveCleanup(); + cleanupAnchor = undefined; + }; + + return { lookup, publish, close }; +}; + +export type DarwinHelperCache = ReturnType; diff --git a/src/process/DarwinProcessRunTokenReader.ts b/src/process/DarwinProcessRunTokenReader.ts index 333c70a68c..7de6ebe20b 100644 --- a/src/process/DarwinProcessRunTokenReader.ts +++ b/src/process/DarwinProcessRunTokenReader.ts @@ -20,6 +20,7 @@ import type { import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; import { safeParseJson } from "../domain/safeJson.js"; import { boundedUtf8Text } from "./boundedUtf8Text.js"; +import { createDarwinHelperCache } from "./DarwinHelperCache.js"; import { snapshotEnvironment } from "./snapshotEnvironment.js"; import { execFileOutput, @@ -394,6 +395,19 @@ const waitForCompilation = async ( }); }; +/** The kernel or policy refused to launch the file, before it could answer. */ +const helperLaunchFailed = (cause: unknown): boolean => { + const failure = execFileOutputFailure(cause); + // spawn rejects with the bare errno before any output exists. + const code = + failure?.code ?? + (cause instanceof Error && "code" in cause ? cause.code : undefined); + return ( + ["EACCES", "EPERM", "ENOEXEC", "ENOENT"].includes(String(code)) || + (failure?.signal === "SIGKILL" && !failure.killed) + ); +}; + /** Explicit tool selection; direct Swift compilation also needs its matching SDK. */ export type DarwinProcessReaderCompiler = | { readonly kind: "xcrun"; readonly executable: string } @@ -417,6 +431,13 @@ export const createDarwinProcessRunTokenReader = ( * reader because cleanup only removes the per-run helper root. */ readonly moduleCachePath?: string; + /** + * REA-owned per-user directory that reuses a verified compiled helper + * across processes with the xcrun compiler. Unset compiles once per + * reader, as callers selecting their own compiler, module cache, or write + * boundary expect. + */ + readonly helperCacheDirectory?: string; } = {}, ) => { const compiler = options.compiler ?? { @@ -429,8 +450,21 @@ export const createDarwinProcessRunTokenReader = ( environment, }); const moduleCachePath = options.moduleCachePath ?? ""; + const helperCache = + options.helperCacheDirectory === undefined || compiler.kind !== "xcrun" + ? undefined + : createDarwinHelperCache({ + directory: options.helperCacheDirectory, + sources: sourceFiles, + xcrun: compiler.executable, + environment, + }); let root: ProcessReaderRoot | undefined; let executable: string | undefined; + // The shared copy runs while it launches; this reader's own helper is kept. + let sharedExecutable: string | undefined; + let perRunExecutable: string | undefined; + let bypassCache = false; let compilation: ProcessReaderCompilation | undefined; let closePromise: Promise | undefined; let closed = false; @@ -489,6 +523,16 @@ export const createDarwinProcessRunTokenReader = ( "Direct Darwin helper compilation requires absolute REA_DARWIN_SWIFTC_PATH and SDKROOT paths selecting a compatible installed Swift compiler and macOS SDK.", ); } + const cached = bypassCache + ? undefined + : await helperCache?.lookup(signal); + if (signal.aborted || closed) throw abortReason(signal); + // A verified shared entry needs no per-run root and is never removed. + if (cached?.kind === "hit") { + sharedExecutable = cached.executable; + executable = cached.executable; + return cached.executable; + } operationRoot = { path: await mkdtemp(join(tmpdir(), "rea-process-token-")), }; @@ -532,8 +576,16 @@ export const createDarwinProcessRunTokenReader = ( throw compilerInspectionError(cause, compilerTimeoutMs, compiler); } if (signal.aborted || closed) throw abortReason(signal); - executable = output; - return output; + // The per-run root stays owned until close even when the shared copy runs. + const shared = + cached?.kind === "miss" + ? await helperCache?.publish(cached.key, output, signal) + : undefined; + if (signal.aborted || closed) throw abortReason(signal); + perRunExecutable = output; + sharedExecutable = shared; + executable = shared ?? output; + return executable; } catch (cause: unknown) { operation.primaryFailure = { cause }; if (operationRoot !== undefined) { @@ -644,8 +696,7 @@ export const createDarwinProcessRunTokenReader = ( ): Promise => { if (closed) return Promise.reject(new ProcessAdmissionError("reader_closed")); - const pending = (async () => { - const binary = await compile(signal); + const execute = async (binary: string) => { const { stdout } = await execFileOutput(binary, arguments_, { env: environment, timeout: 15_000, @@ -654,6 +705,22 @@ export const createDarwinProcessRunTokenReader = ( }); signal?.throwIfAborted(); return stdout; + }; + const pending = (async () => { + const binary = await compile(signal); + // Decide before awaiting: a concurrent fallback may already switch. + const usedShared = binary === sharedExecutable; + try { + return await execute(binary); + } catch (cause: unknown) { + if (!usedShared || !helperLaunchFailed(cause)) throw cause; + // The shared copy cannot run here, for example from a noexec home + // volume; this reader keeps its own helper from now on. + bypassCache = true; + sharedExecutable = undefined; + if (executable === binary) executable = perRunExecutable; + return execute(await compile(signal)); + } })(); activeCommands.add(pending); return pending.finally(() => { @@ -774,7 +841,24 @@ export const createDarwinProcessRunTokenReader = ( await operation.promise; } await Promise.allSettled(activeCommands); - await cleanupRoot(); + // Each owner keeps its own failed cleanup for the next close attempt; + // a failure of both stays incomplete until both owners recover. + const [rootCleanup, cacheCleanup] = await Promise.allSettled([ + cleanupRoot(), + helperCache?.close(), + ]); + if ( + rootCleanup.status === "rejected" && + cacheCleanup.status === "rejected" && + rootCleanup.reason instanceof ProviderCleanupError && + cacheCleanup.reason instanceof ProviderCleanupError + ) + throw analysisErrorWithCleanupFailure( + rootCleanup.reason, + cacheCleanup.reason, + ).bindCleanupDependencies([rootCleanup.reason, cacheCleanup.reason]); + if (rootCleanup.status === "rejected") throw rootCleanup.reason; + if (cacheCleanup.status === "rejected") throw cacheCleanup.reason; })().catch((cause: unknown) => { if (closePromise === pendingClose) closePromise = undefined; throw cause; diff --git a/src/process/ProcessOwnershipObservation.ts b/src/process/ProcessOwnershipObservation.ts index 15ca968f41..10f67cd8fc 100644 --- a/src/process/ProcessOwnershipObservation.ts +++ b/src/process/ProcessOwnershipObservation.ts @@ -24,6 +24,7 @@ import { createDarwinProcessRunTokenReader, type DarwinProcessReaderCompiler, } from "./DarwinProcessRunTokenReader.js"; +import { darwinHelperCacheDirectory } from "./DarwinHelperCache.js"; import { ProcessRunTokenReadError, readProcessRunTokens, @@ -1096,6 +1097,11 @@ export const createSystemProcessOwnershipHost = ( executable: swiftcPath, sdkRoot: hostEnvironment.SDKROOT ?? "", }); + const helperCacheDirectory = darwinHelperCacheDirectory({ + home: hostEnvironment.HOME, + compilerSelected: darwinCompiler !== undefined, + moduleCachePath: darwinModuleCachePath, + }); const darwinTokens = platform === "darwin" ? createDarwinProcessRunTokenReader({ @@ -1104,6 +1110,9 @@ export const createSystemProcessOwnershipHost = ( ...(darwinModuleCachePath === undefined ? {} : { moduleCachePath: darwinModuleCachePath }), + ...(helperCacheDirectory === undefined + ? {} + : { helperCacheDirectory }), ...(options.compileTimeoutMs === undefined ? {} : { compileTimeoutMs: options.compileTimeoutMs }), diff --git a/tests/boundary/filesystem/managedDataPurge.test.ts b/tests/boundary/filesystem/managedDataPurge.test.ts index 5465247aeb..e5dd717930 100644 --- a/tests/boundary/filesystem/managedDataPurge.test.ts +++ b/tests/boundary/filesystem/managedDataPurge.test.ts @@ -204,6 +204,22 @@ it.each([ }, ); +it("purges the Darwin ownership-helper cache with the default Kuna namespace", async () => { + const home = await createTestTempDirectory("rea-managed-helper-cache-"); + const helper = join(home, "Library/Caches/rea/process-helper"); + await mkdir(join(helper, "a".repeat(64)), { recursive: true }); + await writeFile(join(helper, "a".repeat(64), "reader"), "REA helper"); + const host = systemUninstallHost(home, undefined, {}, "darwin"); + try { + expect(await host.purgeData()).toContainEqual( + expect.objectContaining({ name: "process-helper", status: "removed" }), + ); + await expect(lstat(helper)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + await host.close?.(); + } +}); + it("reports the Hopper producer's invalid cache selection rather than silently falling back", async () => { const home = await createTestTempDirectory("rea-managed-cache-invalid-"); const fallback = join(home, ".cache", "rea", "hopper-releases"); diff --git a/tests/boundary/process/darwinHelperCache.test.ts b/tests/boundary/process/darwinHelperCache.test.ts new file mode 100644 index 0000000000..192622ca55 --- /dev/null +++ b/tests/boundary/process/darwinHelperCache.test.ts @@ -0,0 +1,527 @@ +import { execFile } from "node:child_process"; +import { + chmod, + lstat, + mkdir, + readFile, + readdir, + realpath, + rm, + stat, + symlink, + utimes, + writeFile, +} from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { expect, it, vi } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { writeFakeXcrun } from "../../fixtures/darwinHelperToolchain.js"; +import { + aclListingGrantsWrite, + createDarwinHelperCache, + darwinHelperCacheDirectory, + defaultDarwinHelperCacheDirectory, +} from "../../../src/process/DarwinHelperCache.js"; +import { createDarwinProcessRunTokenReader } from "../../../src/process/DarwinProcessRunTokenReader.js"; +import { createSystemProcessOwnershipHost } from "../../../src/process/ProcessOwnershipObservation.js"; + +const execFileAsync = promisify(execFile); +const posix = process.platform !== "win32"; +const onDarwin = process.platform === "darwin"; + +const exists = (path: string): Promise => + lstat(path).then( + () => true, + () => false, + ); + +/** One cache over fixture sources and toolchain, plus a helper to publish. */ +const fixtureCache = async (prefix: string, rootParent = "cache") => { + const directory = await createTestTempDirectory(prefix); + const source = join(directory, "Reader.swift"); + await writeFile(source, "// helper source v1\n"); + const compiled = join(directory, "compiled-reader"); + await writeFile(compiled, "#!/bin/sh\necho compiled\n"); + await chmod(compiled, 0o755); + const xcrun = await writeFakeXcrun(directory); + const root = join(directory, rootParent, "process-helper"); + const create = () => + createDarwinHelperCache({ + directory: root, + sources: [source], + xcrun: xcrun.executable, + environment: process.env, + }); + return { directory, source, compiled, root, xcrun, create }; +}; + +const missKey = async ( + cache: ReturnType, +): Promise => { + const lookup = await cache.lookup(); + if (lookup.kind !== "miss") + throw new Error(`Expected a miss: ${lookup.kind}`); + return lookup.key; +}; + +it.skipIf(!posix)( + "publishes a private verified entry that a later process reuses", + async () => { + const fixture = await fixtureCache("rea-helper-cache-publish-"); + const first = fixture.create(); + const key = await missKey(first); + const published = await first.publish(key, fixture.compiled); + expect(published).toBe(join(await realpath(fixture.root), key, "reader")); + expect((await stat(fixture.root)).mode & 0o777).toBe(0o700); + expect((await stat(join(fixture.root, key))).mode & 0o777).toBe(0o700); + expect((await stat(published ?? "")).mode & 0o777).toBe(0o700); + expect( + (await stat(join(fixture.root, key, "manifest.json"))).mode & 0o777, + ).toBe(0o600); + expect(await readdir(fixture.root)).toEqual([key]); + // A fresh cache object stands in for the next REA process. + expect(await fixture.create().lookup()).toEqual({ + kind: "hit", + executable: published, + }); + await first.close(); + }, +); + +it.skipIf(!posix)( + "keys entries on helper sources and the toolchain xcrun resolves", + async () => { + const fixture = await fixtureCache("rea-helper-cache-key-"); + const keys = [await missKey(fixture.create())]; + await writeFile(fixture.source, "// helper source v2\n"); + keys.push(await missKey(fixture.create())); + // An in-place compiler or SDK update keeps paths but changes identity. + await writeFile(fixture.xcrun.swiftc, "compiler v2 (longer)"); + keys.push(await missKey(fixture.create())); + const later = new Date(Date.now() + 60_000); + await utimes(join(fixture.xcrun.sdk, "SDKSettings.json"), later, later); + keys.push(await missKey(fixture.create())); + expect(new Set(keys).size).toBe(keys.length); + }, +); + +it.skipIf(!posix)( + "never executes or removes an invalid, linked, or loosened entry", + async () => { + const fixture = await fixtureCache("rea-helper-cache-invalid-"); + const corruptions: Record Promise> = { + "changed helper bytes": (entry) => + writeFile(join(entry, "reader"), "#!/bin/sh\necho replaced\n"), + "manifest for another key": async (entry) => { + const manifest = JSON.parse( + await readFile(join(entry, "manifest.json"), "utf8"), + ) as Record; + await writeFile( + join(entry, "manifest.json"), + JSON.stringify({ ...manifest, key: "0".repeat(64) }), + ); + }, + "group-writable entry": (entry) => chmod(entry, 0o770), + "group-writable helper": (entry) => chmod(join(entry, "reader"), 0o770), + "malformed manifest": (entry) => + writeFile(join(entry, "manifest.json"), "{"), + "linked helper": async (entry) => { + const target = join(fixture.directory, "elsewhere"); + await writeFile(target, "#!/bin/sh\necho elsewhere\n"); + await chmod(target, 0o700); + await rm(join(entry, "reader")); + await symlink(target, join(entry, "reader")); + }, + "manifest FIFO": async (entry) => { + await rm(join(entry, "manifest.json")); + await execFileAsync("/usr/bin/mkfifo", [join(entry, "manifest.json")]); + }, + }; + for (const [name, corrupt] of Object.entries(corruptions)) { + const cache = fixture.create(); + const key = await missKey(cache); + expect(await cache.publish(key, fixture.compiled), name).toBeDefined(); + await corrupt(join(fixture.root, key)); + // Opening the FIFO must not wait for a writer. + expect(await fixture.create().lookup(), name).toEqual({ + kind: "unavailable", + }); + // Removal could race another process that verified this path. + expect(await exists(join(fixture.root, key)), name).toBe(true); + await chmod(join(fixture.root, key), 0o700); + await rm(join(fixture.root, key), { recursive: true, force: true }); + } + }, +); + +it.skipIf(!posix)( + "never trusts a linked entry, an untrusted root, or an ancestor others can write", + async () => { + const fixture = await fixtureCache("rea-helper-cache-untrusted-"); + const key = await missKey(fixture.create()); + const valid = join(fixture.directory, "valid-entry"); + await mkdir(valid, { mode: 0o700 }); + await symlink(valid, join(fixture.root, key)); + expect(await fixture.create().lookup()).toEqual({ kind: "unavailable" }); + expect((await lstat(join(fixture.root, key))).isSymbolicLink()).toBe(true); + + const loose = await fixtureCache("rea-helper-cache-loose-root-"); + await mkdir(loose.root, { recursive: true }); + await chmod(loose.root, 0o777); + const looseCache = loose.create(); + expect(await looseCache.lookup()).toEqual({ kind: "unavailable" }); + expect(await looseCache.publish("a".repeat(64), loose.compiled)).toBe( + undefined, + ); + expect(await readdir(loose.root)).toEqual([]); + + // Another user could rename the verified entry out of a shared parent. + const shared = await fixtureCache( + "rea-helper-cache-shared-parent-", + "shared/cache", + ); + await mkdir(join(shared.directory, "shared"), { recursive: true }); + await chmod(join(shared.directory, "shared"), 0o777); + expect(await shared.create().lookup()).toEqual({ kind: "unavailable" }); + + const blocked = await fixtureCache("rea-helper-cache-blocked-root-"); + await writeFile(join(blocked.directory, "cache"), "not a directory"); + expect(await blocked.create().lookup()).toEqual({ kind: "unavailable" }); + }, +); + +it.skipIf(!posix)( + "keeps the first concurrent publication and leaves no staging directory", + async () => { + const fixture = await fixtureCache("rea-helper-cache-race-"); + const key = await missKey(fixture.create()); + const caches = Array.from({ length: 4 }, () => fixture.create()); + const results = await Promise.all( + caches.map((cache) => cache.publish(key, fixture.compiled)), + ); + expect(new Set(results).size).toBe(1); + expect(results[0]).toMatch(/\/reader$/u); + expect(await readdir(fixture.root)).toEqual([key]); + await Promise.all(caches.map((cache) => cache.close())); + }, +); + +it.skipIf(!posix)( + "returns no shared helper when the compiled output or toolchain is unusable", + async () => { + const fixture = await fixtureCache("rea-helper-cache-unusable-"); + const key = await missKey(fixture.create()); + expect( + await fixture.create().publish(key, join(fixture.directory, "absent")), + ).toBeUndefined(); + const empty = join(fixture.directory, "empty"); + await writeFile(empty, ""); + expect(await fixture.create().publish(key, empty)).toBeUndefined(); + expect(await readdir(fixture.root)).toEqual([]); + await rm(fixture.xcrun.swiftc); + expect(await fixture.create().lookup()).toEqual({ kind: "unavailable" }); + }, +); + +it("shares a helper only for the default toolchain and an absolute home", () => { + const select = ( + overrides: Partial[0]>, + ) => + darwinHelperCacheDirectory({ + home: "/Users/analyst", + compilerSelected: false, + moduleCachePath: undefined, + ...overrides, + }); + expect(select({})).toBe("/Users/analyst/Library/Caches/rea/process-helper"); + // An empty module cache setting keeps the default, as documented. + expect(select({ moduleCachePath: "" })).toBe( + "/Users/analyst/Library/Caches/rea/process-helper", + ); + expect(select({ moduleCachePath: "/boundary/modules" })).toBeUndefined(); + expect(select({ compilerSelected: true })).toBeUndefined(); + expect(select({ home: "relative/home" })).toBeUndefined(); + expect(select({ home: undefined })).toBeUndefined(); +}); + +it("keeps the caller's home path segments instead of normalizing them", () => { + // A symlink followed by `..` must resolve on the filesystem, not lexically. + expect(defaultDarwinHelperCacheDirectory("/Users/analyst/link/../home")).toBe( + "/Users/analyst/link/../home/Library/Caches/rea/process-helper", + ); +}); + +it.skipIf(!posix)( + "stops stalled toolchain lookups when preparation is cancelled", + async () => { + const directory = await createTestTempDirectory("rea-helper-cache-stall-"); + const source = join(directory, "Reader.swift"); + await writeFile(source, "// helper source\n"); + const xcrun = await writeFakeXcrun(directory, false, true); + const cache = createDarwinHelperCache({ + directory: join(directory, "process-helper"), + sources: [source], + xcrun: xcrun.executable, + environment: process.env, + }); + const started = Date.now(); + expect(await cache.lookup(AbortSignal.timeout(200))).toEqual({ + kind: "unavailable", + }); + // Far below the 15 s lookup deadline: both lookups ended with the signal. + expect(Date.now() - started).toBeLessThan(5_000); + + const reader = createDarwinProcessRunTokenReader({ + compiler: { kind: "xcrun", executable: xcrun.executable }, + helperCacheDirectory: join(directory, "reader-cache"), + }); + const controller = new AbortController(); + const prepared = reader.prepare(controller.signal); + setTimeout(() => controller.abort(new Error("caller cancelled")), 200); + const cancelled = Date.now(); + await expect(prepared).rejects.toThrow("caller cancelled"); + await reader.close(); + expect(Date.now() - cancelled).toBeLessThan(5_000); + expect(await xcrun.compilations()).toBe(0); + }, +); + +it("rejects only ACL entries that allow another principal to write", () => { + const entry = (line: string) => + `drwx------+ 3 analyst staff 96 31 Oct 09:00 /Users/analyst/x\n${line}\n`; + // macOS homes ship with this entry; it must not disable the cache. + expect(aclListingGrantsWrite(entry(" 0: group:everyone deny delete"))).toBe( + false, + ); + expect( + aclListingGrantsWrite(entry(" 0: group:everyone inherited deny delete")), + ).toBe(false); + expect( + aclListingGrantsWrite(entry(" 0: user:nobody allow read,search")), + ).toBe(false); + expect( + aclListingGrantsWrite( + entry(" 0: FFFFEEEE-DDDD-CCCC-BBBB-AAAAFFFFFFFE allow write,append"), + ), + ).toBe(true); + expect( + aclListingGrantsWrite( + entry(" 1: user:nobody inherited allow add_file,file_inherit"), + ), + ).toBe(true); + expect(aclListingGrantsWrite("-rw------- 1 analyst staff 0 x\n")).toBe(false); +}); + +it.skipIf(!onDarwin)( + "never trusts an entry or ancestor whose ACL lets another user write", + async () => { + const grants: Record< + string, + (fixture: { + root: string; + key: string; + directory: string; + }) => Promise + > = { + "helper write": ({ root, key }) => + execFileAsync("/bin/chmod", [ + "+a", + "user:nobody allow write", + join(root, key, "reader"), + ]).then(() => undefined), + "entry add_file": ({ root, key }) => + execFileAsync("/bin/chmod", [ + "+a", + "user:nobody allow add_file,delete_child", + join(root, key), + ]).then(() => undefined), + "ancestor delete_child": ({ directory }) => + execFileAsync("/bin/chmod", [ + "+a", + "user:nobody allow delete_child", + join(directory, "cache"), + ]).then(() => undefined), + }; + for (const [name, grant] of Object.entries(grants)) { + const fixture = await fixtureCache(`rea-helper-cache-acl-`); + const cache = fixture.create(); + const key = await missKey(cache); + expect(await cache.publish(key, fixture.compiled), name).toBeDefined(); + // The default home ACL keeps sharing. + await execFileAsync("/bin/chmod", [ + "+a", + "group:everyone deny delete", + fixture.root, + ]); + expect((await fixture.create().lookup()).kind, name).toBe("hit"); + await grant({ root: fixture.root, key, directory: fixture.directory }); + expect(await fixture.create().lookup(), name).toEqual({ + kind: "unavailable", + }); + await execFileAsync("/bin/chmod", ["-RN", fixture.directory]); + } + }, +); + +const fixtureProcess = { + pid: 123, + parentPid: 1, + processGroupId: 123, + state: "S", + command: "fixture", +}; + +it.skipIf(!posix)( + "runs the shared helper in later readers without recompiling and keeps it after close", + async () => { + const directory = await createTestTempDirectory("rea-helper-cache-reader-"); + const helperCacheDirectory = join(directory, "process-helper"); + const xcrun = await writeFakeXcrun(directory); + const create = () => + createDarwinProcessRunTokenReader({ + compiler: { kind: "xcrun", executable: xcrun.executable }, + helperCacheDirectory, + }); + const first = create(); + let shared: string; + try { + shared = await first.prepare(); + expect(shared.endsWith("/reader")).toBe(true); + expect(shared.includes("/process-helper/")).toBe(true); + expect((await first.read([fixtureProcess])).get(123)).toEqual({ + state: "readable", + runId: "fixture", + }); + } finally { + await first.close(); + } + const [compileRoot] = await xcrun.roots(); + expect(compileRoot).toBeDefined(); + expect(await exists(compileRoot ?? "")).toBe(false); + expect(await exists(shared)).toBe(true); + + const second = create(); + try { + expect(await second.prepare()).toBe(shared); + expect((await second.read([fixtureProcess])).get(123)).toEqual({ + state: "readable", + runId: "fixture", + }); + } finally { + await second.close(); + } + expect(await xcrun.compilations()).toBe(1); + expect(await exists(shared)).toBe(true); + }, +); + +it.skipIf(!posix)( + "falls back to its own helper when a verified shared copy cannot launch", + async () => { + const directory = await createTestTempDirectory("rea-helper-cache-noexec-"); + const helperCacheDirectory = join(directory, "process-helper"); + const xcrun = await writeFakeXcrun(directory); + const create = () => + createDarwinProcessRunTokenReader({ + compiler: { kind: "xcrun", executable: xcrun.executable }, + helperCacheDirectory, + }); + const first = create(); + let shared: string; + try { + shared = await first.prepare(); + } finally { + await first.close(); + } + const second = create(); + try { + expect(await second.prepare()).toBe(shared); + // The verified copy is refused at launch, as on a noexec volume. + await chmod(shared, 0o600); + // Concurrent reads that both launched the shared copy both fall back. + const reads = await Promise.all([ + second.read([fixtureProcess]), + second.read([fixtureProcess]), + ]); + for (const read of reads) + expect(read.get(123)).toEqual({ state: "readable", runId: "fixture" }); + } finally { + await second.close(); + } + expect(await xcrun.compilations()).toBe(2); + expect((await stat(shared)).mode & 0o777).toBe(0o600); + }, +); + +it.skipIf(!posix)( + "publishes nothing when helper compilation is cancelled", + async () => { + const directory = await createTestTempDirectory("rea-helper-cache-cancel-"); + const helperCacheDirectory = join(directory, "process-helper"); + const xcrun = await writeFakeXcrun(directory, true); + const reader = createDarwinProcessRunTokenReader({ + compiler: { kind: "xcrun", executable: xcrun.executable }, + helperCacheDirectory, + }); + const controller = new AbortController(); + try { + const prepared = reader.prepare(controller.signal); + const deadline = Date.now() + 10_000; + while ((await xcrun.compilations()) === 0 && Date.now() < deadline) + await new Promise((resolve) => setTimeout(resolve, 10)); + controller.abort(new Error("caller cancelled")); + await expect(prepared).rejects.toThrow("caller cancelled"); + } finally { + await reader.close(); + } + expect(await readdir(helperCacheDirectory)).toEqual([]); + const [compileRoot] = await xcrun.roots(); + expect(await exists(compileRoot ?? "")).toBe(false); + }, +); + +it.skipIf(!onDarwin)( + "reuses the real default-toolchain helper in a later host without compiling", + async () => { + const home = await createTestTempDirectory("rea-helper-cache-home-"); + const cacheRoot = join(home, "Library/Caches/rea/process-helper"); + const environment: NodeJS.ProcessEnv = { ...process.env, HOME: home }; + delete environment.REA_DARWIN_SWIFTC_PATH; + delete environment.REA_DARWIN_MODULE_CACHE_PATH; + const prepare = async (selected: NodeJS.ProcessEnv) => { + const host = createSystemProcessOwnershipHost("darwin", selected); + try { + await host.prepare?.(AbortSignal.timeout(180_000)); + const current = (await host.listProcesses()).find( + ({ pid }) => pid === process.pid, + ); + if (current === undefined || host.processIdentities === undefined) + throw new Error("Darwin host omitted the current process identity"); + expect( + (await host.processIdentities([current])).get(process.pid), + ).toMatchObject({ state: "readable" }); + } finally { + await host.close?.(); + } + }; + await prepare(environment); + const [key, ...others] = await readdir(cacheRoot); + expect(others).toEqual([]); + const helper = join(cacheRoot, key ?? "", "reader"); + const published = await stat(helper); + // A compile would need a per-run root under TMPDIR; deny it. + const noTemporary = join(home, "no-temporary"); + await mkdir(noTemporary, { mode: 0o500 }); + vi.stubEnv("TMPDIR", noTemporary); + try { + await prepare(environment); + } finally { + vi.unstubAllEnvs(); + } + expect((await stat(helper)).ino).toBe(published.ino); + expect(await readdir(cacheRoot)).toEqual([key]); + expect(await readdir(noTemporary)).toEqual([]); + }, +); diff --git a/tests/fixtures/darwinHelperToolchain.ts b/tests/fixtures/darwinHelperToolchain.ts new file mode 100644 index 0000000000..11d54b2285 --- /dev/null +++ b/tests/fixtures/darwinHelperToolchain.ts @@ -0,0 +1,61 @@ +import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +/** + * Fake xcrun: answers toolchain lookups from fixture files and, given + * `swiftc … -o OUT`, writes a helper that reads every requested PID. + */ +export const writeFakeXcrun = async ( + directory: string, + blocking = false, + stalledLookups = false, +) => { + const executable = join(directory, "fake-xcrun"); + const swiftc = join(directory, "toolchain", "swiftc"); + const sdk = join(directory, "sdk"); + const countPath = join(directory, "compile-count"); + const rootsPath = join(directory, "compile-roots"); + await mkdir(join(directory, "toolchain"), { recursive: true }); + await mkdir(sdk, { recursive: true }); + await writeFile(swiftc, "compiler v1"); + await writeFile(join(sdk, "SDKSettings.json"), '{"Version":"1"}'); + const helper = `#!/usr/bin/env node +const pids = process.argv.slice(2).filter((value) => value !== "--identities"); +process.stdout.write(JSON.stringify({ results: pids.map((pid) => ({ pid: Number(pid), state: "readable", run_id: "fixture" })) })); +`; + await writeFile( + executable, + `#!/usr/bin/env node +const fs = process.getBuiltinModule("node:fs"); +const path = process.getBuiltinModule("node:path"); +const args = process.argv.slice(2); +const compile = () => { + const outputIndex = args.indexOf("-o"); + if (outputIndex < 0) process.exit(64); + const output = args[outputIndex + 1]; + fs.appendFileSync(${JSON.stringify(countPath)}, "x"); + fs.appendFileSync(${JSON.stringify(rootsPath)}, path.dirname(output) + "\\n"); + if (${String(blocking)}) return setInterval(() => {}, 1000); + fs.writeFileSync(output, ${JSON.stringify(helper)}); + fs.chmodSync(output, 0o755); +}; +if (args[0] === "swiftc") compile(); +else if (${String(stalledLookups)}) setInterval(() => {}, 1000); +else if (args[0] === "--find") process.stdout.write(${JSON.stringify(swiftc)} + "\\n"); +else if (args[0] === "--show-sdk-path") process.stdout.write(${JSON.stringify(sdk)} + "\\n"); +else process.exit(64); +`, + ); + await chmod(executable, 0o755); + return { + executable, + swiftc, + sdk, + compilations: async () => + (await readFile(countPath, "utf8").catch(() => "")).length, + roots: async () => + (await readFile(rootsPath, "utf8").catch(() => "")) + .split("\n") + .filter((line) => line !== ""), + }; +}; diff --git a/tests/process-global/darwinHelperCacheCleanup.test.ts b/tests/process-global/darwinHelperCacheCleanup.test.ts new file mode 100644 index 0000000000..571801cbc0 --- /dev/null +++ b/tests/process-global/darwinHelperCacheCleanup.test.ts @@ -0,0 +1,130 @@ +import { chmod, mkdir, readdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it, vi } from "vitest"; + +const fault = vi.hoisted(() => ({ removals: 0, roots: 0, renames: 0 })); +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + rename: async (...args: Parameters) => { + if (String(args[0]).includes("/.publish-") && fault.renames > 0) { + fault.renames -= 1; + throw Object.assign(new Error("controlled lost publication race"), { + code: "ENOTEMPTY", + }); + } + return actual.rename(...args); + }, + rm: async (...args: Parameters) => { + if (String(args[0]).includes("/.publish-") && fault.removals > 0) { + fault.removals -= 1; + throw new Error("controlled staging removal failure"); + } + if (String(args[0]).includes("rea-process-token-") && fault.roots > 0) { + fault.roots -= 1; + throw new Error("controlled helper-root removal failure"); + } + return actual.rm(...args); + }, + }; +}); + +import { ProviderCleanupError } from "../../src/domain/providerCleanupError.js"; +import { createDarwinHelperCache } from "../../src/process/DarwinHelperCache.js"; +import { createDarwinProcessRunTokenReader } from "../../src/process/DarwinProcessRunTokenReader.js"; +import { writeFakeXcrun } from "../fixtures/darwinHelperToolchain.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; + +it.skipIf(process.platform === "win32")( + "retains a staging directory whose removal failed until a close retry removes it", + async () => { + const directory = await createTestTempDirectory("rea-helper-cache-retry-"); + const source = join(directory, "Reader.swift"); + await writeFile(source, "// helper source\n"); + const compiled = join(directory, "compiled-reader"); + await writeFile(compiled, "#!/bin/sh\necho compiled\n"); + await chmod(compiled, 0o755); + const xcrun = await writeFakeXcrun(directory); + const root = join(directory, "process-helper"); + const cache = createDarwinHelperCache({ + directory: root, + sources: [source], + xcrun: xcrun.executable, + environment: process.env, + }); + const lookup = await cache.lookup(); + if (lookup.kind !== "miss") throw new Error("Expected a cache miss"); + // An occupied, invalid entry makes this publication lose its rename. + await mkdir(join(root, lookup.key), { mode: 0o700 }); + await writeFile(join(root, lookup.key, "unrelated"), "occupied"); + // Publication, then two close attempts, fail to remove the staging copy. + fault.removals = 3; + expect(await cache.publish(lookup.key, compiled)).toBeUndefined(); + const staging = (await readdir(root)).filter((name) => + name.startsWith(".publish-"), + ); + expect(staging).toHaveLength(1); + const failed: unknown = await cache + .close() + .catch((cause: unknown) => cause); + expect(failed).toBeInstanceOf(ProviderCleanupError); + expect(failed).toMatchObject({ + cleanupResources: [ + join( + await import("node:fs/promises").then(({ realpath }) => + realpath(root), + ), + staging[0] ?? "", + ), + ], + }); + expect(await readdir(root)).toContain(staging[0]); + expect(failed).toMatchObject({ cleanupIncomplete: true }); + // A second failed attempt depends on the first returned error. + const again: unknown = await cache.close().catch((cause: unknown) => cause); + expect(again).toMatchObject({ cleanupIncomplete: true }); + await cache.close(); + expect(await readdir(root)).toEqual([lookup.key]); + // Every error returned while cleanup was pending now reports release. + expect(failed).toMatchObject({ cleanupIncomplete: false }); + expect(again).toMatchObject({ cleanupIncomplete: false }); + }, +); + +it.skipIf(process.platform === "win32")( + "keeps a close incomplete until both the helper root and the cache recover", + async () => { + const directory = await createTestTempDirectory("rea-helper-cache-both-"); + const xcrun = await writeFakeXcrun(directory); + const helperCacheDirectory = join(directory, "process-helper"); + const reader = createDarwinProcessRunTokenReader({ + compiler: { kind: "xcrun", executable: xcrun.executable }, + helperCacheDirectory, + }); + // Publication loses its rename; publication and two closes cannot remove + // its staging copy, while the helper root fails only the first close. + fault.renames = 1; + fault.removals = 3; + expect(await reader.prepare()).toContain("rea-process-token-"); + fault.roots = 1; + const failed: unknown = await reader + .close() + .catch((cause: unknown) => cause); + expect(failed).toMatchObject({ cleanupIncomplete: true }); + const resources = (failed as { cleanupResources: readonly string[] }) + .cleanupResources; + expect(resources.some((path) => path.includes("rea-process-token-"))).toBe( + true, + ); + expect(resources.some((path) => path.includes("/.publish-"))).toBe(true); + // The root recovers first; the error must still wait for the cache. + const again: unknown = await reader + .close() + .catch((cause: unknown) => cause); + expect(again).toMatchObject({ cleanupIncomplete: true }); + expect(failed).toMatchObject({ cleanupIncomplete: true }); + await reader.close(); + expect(failed).toMatchObject({ cleanupIncomplete: false }); + }, +);