Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions src/process.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { spawn, spawnSync } from 'node:child_process';
import { createWriteStream } from 'node:fs';
import { canonicalWorkspaceLabelID } from './label-id.js';
import { ProcessError } from './errors.js';

Expand Down Expand Up @@ -94,6 +95,67 @@ export function run(binary, args, options = {}) {
});
}

/**
* Runs a command and writes its stdout to a file, so the destination never has to be
* passed to the command as an argument.
*
* That matters on Windows: GNU tar reads `C:\path` as a remote `host:path` and fails
* with "Cannot connect to C", while the bsdtar shipped in System32 accepts it. Which one
* runs is decided by PATH order, so the same command works or fails depending on whether
* Git for Windows appears first — and Git for Windows very often does. Handing the path
* over stdout removes the question instead of detecting the flavour, which would have to
* be re-detected for every tool that takes a path.
*/
export function runToFile(binary, args, destinationPath, options = {}) {
if (typeof binary !== 'string' || !binary || !Array.isArray(args) || args.some((arg) => typeof arg !== 'string')) {
return Promise.reject(new TypeError('Process runner requires an executable and string argument array'));
}
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const display = formatCommand(binary, args, options.sensitiveArgs ?? [], []);
return new Promise((resolve, reject) => {
const output = createWriteStream(destinationPath);
const child = spawn(binary, args, {
cwd: options.cwd,
env: options.env ? { ...process.env, ...options.env } : process.env,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
let stderr = '';
let settled = false;
const fail = (kind, message, details = {}) => {
if (settled) return;
settled = true;
clearTimeout(timer);
output.destroy();
reject(new ProcessError(`${display} ${message}${stderr ? `: ${stderr.trim()}` : ''}`.trim(), { kind, stderr, ...details }));
};
const timer = setTimeout(() => {
if (process.platform === 'win32' && child.pid) spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
else child.kill('SIGKILL');
fail('timeout', `timed out after ${timeoutMs}ms`);
}, timeoutMs);

child.stderr.on('data', (chunk) => { if (stderr.length < 64 * 1024) stderr += String(chunk); });
child.on('error', (error) => fail('spawn', `could not start: ${error.message}`));
output.on('error', (error) => fail('exit', `could not write output: ${error.message}`));
child.stdout.pipe(output);
child.on('close', (code, signal) => {
if (settled) return;
if (code !== 0) {
fail('exit', `failed with ${code ?? signal}`, { exitCode: code, signal });
return;
}
// Resolve only once the file is closed, so a caller may read it immediately.
output.end(() => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve({ stderr });
});
});
});
}

export function spawnBackground(binary, args, options = {}) {
return spawn(binary, args, {
cwd: options.cwd,
Expand Down
7 changes: 5 additions & 2 deletions src/snapshot.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { createReadStream } from 'node:fs';
import { chmod, lstat, mkdtemp, readFile, readdir, readlink, realpath, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
import { run } from './process.js';
import { run, runToFile } from './process.js';

const DEFAULT_LIMITS = Object.freeze({ maxEntries: 100_000, maxBytes: 2 * 1024 ** 3, maxFileBytes: 256 * 1024 ** 2 });
const RESERVED_ROOTS = new Set(['.openchamber', '.openchamber-runtime']);
Expand Down Expand Up @@ -37,7 +37,10 @@ export async function createSourceSnapshot(sourceDirectory, options = {}) {
await chmod(temporaryDirectory, 0o700);
const archivePath = join(temporaryDirectory, 'source.tar');
try {
await run('tar', ['-cf', archivePath, '--exclude', './.git', '.'], { cwd: root, env: { COPYFILE_DISABLE: '1' }, timeoutMs: options.timeoutMs ?? 300_000, maxOutputBytes: 64 * 1024 });
// Written through stdout rather than by naming the file to tar: a Windows path is a
// remote host to GNU tar and an ordinary path to bsdtar, and PATH order decides which
// one answers.
await runToFile('tar', ['-cf', '-', '--exclude', './.git', '.'], archivePath, { cwd: root, env: { COPYFILE_DISABLE: '1' }, timeoutMs: options.timeoutMs ?? 300_000 });
const after = await scanSourceTree(root, limits);
if (JSON.stringify(before.entries) !== JSON.stringify(after.entries)) throw new Error('Workspace source changed while the immutable snapshot was being created');
const generation = createHash('sha256').update(JSON.stringify(before.entries.map(({ mtimeNs, ...entry }) => entry))).digest('hex');
Expand Down
Loading