// ProjectFilePlane over a real local checkout: the second host behind the // seam Contrast's browser already implements. writes land on the user's actual // disk, so nothing here converts content: the plane reads and writes bytes. // // the file set comes from `git ls-files -co --exclude-standard`, which is the // project's own definition of what belongs to it. a hardcoded skip list was // measured against this repo and is not viable: it walked 230,229 files // (16.5 GB) in 4.9 s, where git returns the same project as 14,984 paths in // 67 ms. `index()` is a synchronous whole-project map, so the size of that set // is the whole cost model. import { execFileSync } from 'node:child_process' import fs from 'node:fs' import path from 'node:path' import type { ProjectFileMeta, ProjectFilePlane } from '@rnx/box/plane' export class CheckoutRequiresGitError extends Error {} // a NUL byte is the usual "this is not text" test. it is published as metadata // rather than acted on here: moving a binary file is byte-exact and perfectly // fine, and so is deleting one, but a shell command that produced TEXT cannot // be allowed to overwrite it, because that text is a lossy decode of the // file's own bytes. BoxProjectFs owns that refusal. detecting it at write // time instead is not an option: just-bash's `cat` discards every readFile // error and reports "No such file or directory" whatever the error says, so // the fact has to be in the index where the filesystem layer can read it. const BINARY_SNIFF_BYTES = 8192 // every command a box runs re-indexes first, and the index read waits on a git // subprocess. it used to await one, and that await could never finish: measured // here over 961 refreshes, the call either answered within 314ms (p50 25ms) or // never answered at all, once for 29 seconds while the process's own timer kept // firing every second. that is not a slow git, it is a lost answer, and no // budget reaches it. // // a deadline on the async form does not help either, which is why this is // synchronous. node's `execFile` timeout kills the child and then still waits // for a 'close' event to settle the promise, so the lost event takes the // deadline down with it: the bound below was armed and silent through all 29 // of those seconds. `execFileSync` waits in the runtime instead of on a // javascript event, so there is nothing to miss, and its timeout is enforced // the same way. // // it costs nothing. the entry loop below is already synchronous stat and read, // and under 24-way load the two forms were indistinguishable over 960 calls // each, interleaved: p50 1529ms synchronous against 1520ms asynchronous. // // 10s is roughly fifteen times the slowest full refresh measured on a // 14,889-file checkout (430-840 ms, of which `git ls-files` itself is 67 ms), // so it cannot fire for a project that is merely large or a machine that is // merely busy. const LIST_FILES_TIMEOUT_MS = 10_000 // the same busy machine fails this call in two ways that are both a failed // READ rather than an answer: the spawn raises, and a child exits 0 having // written nothing. docs/testing.md measured the second one directly on // 2026-08-27 under CPU oversubscription, where a git child exited 0 with empty // stdout while the very next `/bin/echo` read back fine. // // the answer to a failed read is to read again. every box command re-indexes // before it runs, so a single lost answer is otherwise the difference between // `touch created.txt` working and the box telling the user it has no file set // to serve: measured here as exit 1 in 103ms, which is what took the box // conformance suite red under a load average of 47. the conditions the two // tests in test/rnxBoxCheckoutPlane.test.ts hold (a git that never answers, a // git that always answers with nothing) are permanent, so they still fail, and // they fail naming the same thing. const LIST_FILES_ATTEMPTS = 3 const LIST_FILES_RETRY_MS = 25 function looksBinary(filePath: string, size: number): boolean { if (size === 0) return false let fd: number | undefined try { fd = fs.openSync(filePath, 'r') const buffer = Buffer.alloc(Math.min(BINARY_SNIFF_BYTES, size)) const read = fs.readSync(fd, buffer, 0, buffer.length, 0) return buffer.subarray(0, read).includes(0) } catch { return false } finally { if (fd !== undefined) fs.closeSync(fd) } } // the box re-indexes before every command, so the sniff above runs against the // whole project every time a user presses return. on a 14,889-file checkout // that is 277-644 ms of a 430-840 ms refresh, and nearly all of it re-reads // files that did not change. a file whose size and mtime both match the last // look has the same first 8 KB, so remember the answer against those two. // `statSync` already returns mtimeMs, and it carries sub-millisecond // resolution here, so the window in which a same-size rewrite could hide is // narrower than a millisecond rather than git's one-second racy-clean case. interface SniffedFile { size: number mtimeMs: number binary: boolean } export class CheckoutFilePlane implements ProjectFilePlane { readonly root: string private entries = new Map() private sniffed = new Map() // the deadline above, as a number a caller can set. what it changes is how // long a wedged git is waited on, never whether it is waited on, so proving // the kill does not have to cost the production deadline. private readonly listTimeoutMs: number private readonly trackedOnly: boolean constructor( root: string, options: { listTimeoutMs?: number; trackedOnly?: boolean } = {}, ) { this.root = path.resolve(root) this.listTimeoutMs = options.listTimeoutMs ?? LIST_FILES_TIMEOUT_MS this.trackedOnly = options.trackedOnly ?? false } index(): ReadonlyMap { return this.entries } private listFiles(): string { // -z keeps paths containing spaces or newlines intact. a local Box sees // tracked plus visible untracked files, while a durable cloud import is // deliberately restricted to the checkout's tracked source. const args = this.trackedOnly ? ['ls-files', '-z'] : ['ls-files', '-co', '--exclude-standard', '-z'] const stdout = execFileSync('git', args, { cwd: this.root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, timeout: this.listTimeoutMs, // stating the environment is not redundant here. bun 1.3.14 resolves // the binary for the synchronous form against the PATH it captured at // startup and ignores a later change to `process.env.PATH`, so without // this it runs a different git than every other call in the process. env: process.env, }) // empty child output never means an empty answer. without this the box // would build an empty index and every read would report the file as // missing, which is a broken project rather than a broken box, and nothing // would say so. if (stdout.length === 0) { throw new CheckoutRequiresGitError( `git listed no ${this.trackedOnly ? 'tracked ' : ''}files in ${this.root}. either the checkout contains no eligible files, or this machine could not read the child process's output.`, ) } return stdout } async refresh(): Promise { let stdout = '' let lastFailure: CheckoutRequiresGitError | undefined for (let attempt = 1; attempt <= LIST_FILES_ATTEMPTS; attempt++) { try { stdout = this.listFiles() lastFailure = undefined break } catch (error) { if (error instanceof CheckoutRequiresGitError) { lastFailure = error } else { const message = error instanceof Error ? error.message : String(error) // the deadline kills with SIGTERM, so that signal is what tells a // bound run apart from git failing on its own terms, and it is worth // naming separately: "git was killed by SIGTERM" reads as git's // problem, when what happened is that it never answered. const killed = error instanceof Error && 'signal' in error && error.signal === 'SIGTERM' lastFailure = new CheckoutRequiresGitError( killed ? `git did not list the project's files in ${this.root} within ${this.listTimeoutMs / 1000}s, so the box has no file set to serve. the checkout may be on a stalled filesystem, or this machine may be too loaded to start a subprocess.` : `could not list the project's files with git in ${this.root}: ${message}`, ) } if (attempt < LIST_FILES_ATTEMPTS) { await new Promise((resolve) => setTimeout(resolve, LIST_FILES_RETRY_MS * attempt), ) } } } if (lastFailure) { lastFailure.message = `${lastFailure.message} (${LIST_FILES_ATTEMPTS} attempts)` throw lastFailure } const next = new Map() // rebuilt alongside the index rather than pruned, so a deleted file's // record cannot outlive it in a box that stays open all day const nextSniffed = new Map() for (const relative of stdout.split('\0')) { if (!relative) continue let stat: fs.Stats try { stat = fs.statSync(path.join(this.root, relative)) } catch { // listed but gone (a deleted-but-tracked file); it is simply not there continue } if (!stat.isFile()) continue const seen = this.sniffed.get(relative) const binary = seen && seen.size === stat.size && seen.mtimeMs === stat.mtimeMs ? seen.binary : looksBinary(path.join(this.root, relative), stat.size) nextSniffed.set(relative, { size: stat.size, mtimeMs: stat.mtimeMs, binary }) next.set(relative, { size: stat.size, // a checkout carries no write policy of its own; if the user can write // the file, so can the shell readOnly: false, binary, }) } this.entries = next this.sniffed = nextSniffed } private absolute(planePath: string): string { const resolved = path.resolve(this.root, planePath) // the shell resolves "." and ".." before calling us, but the plane owns the // checkout boundary and must not depend on that. if (resolved !== this.root && !resolved.startsWith(`${this.root}${path.sep}`)) { throw new Error(`path escapes the project: ${planePath}`) } return resolved } async read(planePath: string): Promise { return fs.promises.readFile(this.absolute(planePath), 'utf8') } async readBytes(planePath: string): Promise { return fs.promises.readFile(this.absolute(planePath)) } async write(planePath: string, content: Uint8Array): Promise { const absolute = this.absolute(planePath) await fs.promises.mkdir(path.dirname(absolute), { recursive: true }) await fs.promises.writeFile(absolute, content) this.entries.set(planePath, { size: content.byteLength, readOnly: false, // the bytes are in hand, so the same NUL test costs nothing here binary: content.subarray(0, BINARY_SNIFF_BYTES).includes(0), }) } async delete(planePath: string): Promise { await fs.promises.rm(this.absolute(planePath), { force: true }) this.entries.delete(planePath) } }