mirror of
https://github.com/morluto/rea.git
synced 2026-11-03 13:26:10 +00:00
* 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/<key>/. 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
528 lines
19 KiB
TypeScript
528 lines
19 KiB
TypeScript
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<boolean> =>
|
|
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<typeof createDarwinHelperCache>,
|
|
): Promise<string> => {
|
|
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<string, (entry: string) => Promise<void>> = {
|
|
"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<string, unknown>;
|
|
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<Parameters<typeof darwinHelperCacheDirectory>[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<void>
|
|
> = {
|
|
"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([]);
|
|
},
|
|
);
|