Skip to content

Commit

Permalink
Use overlay FS when building projects (#144)
Browse files Browse the repository at this point in the history
  • Loading branch information
jakebailey authored Mar 15, 2024
1 parent 45d0fec commit 3b3b4da
Show file tree
Hide file tree
Showing 7 changed files with 287 additions and 124 deletions.
16 changes: 13 additions & 3 deletions .vscode/tasks.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,23 @@
"type": "npm",
"script": "build",
"group": {
"kind": "build",
"isDefault": true
"kind": "build"
},
"problemMatcher": [],
"label": "npm: build",
"detail": "tsc -b ."
},
{
"label": "tsc: watch ./src",
"type": "shell",
"command": "node",
"args": ["${workspaceFolder}/node_modules/typescript/lib/tsc.js", "--build", ".", "--watch"],
"group": "build",
"isBackground": true,
"problemMatcher": [
"$tsc-watch"
]
},
{
"label": "Clean all",
"type": "shell",
Expand All @@ -39,4 +49,4 @@
}
},
]
}
}
205 changes: 87 additions & 118 deletions src/main.ts

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src/utils/execUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export interface SpawnResult {

/** Returns undefined if and only if executions times out. */
export function spawnWithTimeoutAsync(cwd: string, command: string, args: readonly string[], timeoutMs: number, env?: {}): Promise<SpawnResult | undefined> {
console.log(`${cwd}> ${command} ${args.join(" ")}`);
return new Promise<SpawnResult | undefined>((resolve, reject) => {
if (timeoutMs <= 0) {
resolve(undefined);
Expand Down
5 changes: 3 additions & 2 deletions src/utils/gitUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,9 @@ export async function cloneRepoIfNecessary(parentDir: string, repo: Repo): Promi
throw new Error("Repo url cannot be `undefined`");
}

if (!await utils.exists(path.join(parentDir, repo.name))) {
console.log(`Cloning ${repo.url} into ${repo.name}`);
const repoDir = path.join(parentDir, repo.name);
if (!await utils.exists(repoDir)) {
console.log(`Cloning ${repo.url} into ${repoDir}`);

let options = ["--recurse-submodules", "--depth=1"];
if (repo.branch) {
Expand Down
7 changes: 7 additions & 0 deletions src/utils/installPackages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export enum InstallTool {

export interface InstallCommand {
directory: string;
prettyDirectory: string;
tool: InstallTool;
arguments: readonly string[];
}
Expand All @@ -25,6 +26,8 @@ export interface InstallCommand {
export async function installPackages(repoDir: string, ignoreScripts: boolean, quietOutput: boolean, recursiveSearch: boolean, monorepoPackages?: readonly string[], types?: string[]): Promise<InstallCommand[]> {
monorepoPackages = monorepoPackages ?? await utils.getMonorepoOrder(repoDir);

const repoName = path.basename(repoDir);

const isRepoYarn = await utils.exists(path.join(repoDir, "yarn.lock"));
// The existence of .yarnrc.yml indicates that this repo uses yarn 2
const isRepoYarn2 = await utils.exists(path.join(repoDir, ".yarnrc.yml"));
Expand Down Expand Up @@ -119,8 +122,11 @@ export async function installPackages(repoDir: string, ignoreScripts: boolean, q
continue;
}

const prettyDirectory = path.join(repoName, path.relative(repoDir, packageRoot));

commands.push({
directory: packageRoot,
prettyDirectory,
tool,
arguments: args,
});
Expand All @@ -133,6 +139,7 @@ export async function installPackages(repoDir: string, ignoreScripts: boolean, q

commands.push({
directory: packageRoot,
prettyDirectory,
tool: InstallTool.Npm,
arguments: args
});
Expand Down
174 changes: 174 additions & 0 deletions src/utils/overlayFS.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import path from "path";
import { execAsync } from "./execUtils";

export interface OverlayBaseFS {
path: string;
createOverlay(): Promise<OverlayMergedFS>;
}

export interface OverlayMergedFS extends AsyncDisposable {
path: string;
}

export interface DisposableOverlayBaseFS extends OverlayBaseFS, AsyncDisposable {}

const processCwd = process.cwd();

/**
* Creates an overlay FS using a tmpfs mount. A base directory is created on the tmpfs.
* New overlays are created by mounting an overlay on top of the base directory.
*
* This requires root access.
*/
export async function createTempOverlayFS(root: string, diagnosticOutput: boolean): Promise<DisposableOverlayBaseFS> {
await tryUnmount(root);
await rmWithRetryAsRoot(root);
await mkdirAllAsRoot(root);
await execAsync(processCwd, `sudo mount -t tmpfs -o size=4g tmpfs ${root}`);

const lowerDir = path.join(root, "base");
await mkdirAll(lowerDir);

let overlay: OverlayMergedFS | undefined;

async function createOverlay(): Promise<OverlayMergedFS> {
if (overlay) {
throw new Error("Overlay has already been created");
}

// Using short names here as these paths can appear in the summaries.
const overlayRoot = path.join(root, "_");
await rmWithRetryAsRoot(overlayRoot);

const upperDir = path.join(overlayRoot, ".u");
const workDir = path.join(overlayRoot, ".w");
const merged = path.join(overlayRoot, "m");

await mkdirAll(upperDir, workDir, merged);

if (diagnosticOutput) {
await diskUsageRoot(lowerDir);
await diskUsageRoot(overlayRoot);
}

await execAsync(processCwd, `sudo mount -t overlay overlay -o lowerdir=${lowerDir},upperdir=${upperDir},workdir=${workDir} ${merged}`);

overlay = {
path: merged,
[Symbol.asyncDispose]: async () => {
overlay = undefined;
if (diagnosticOutput) {
await diskUsageRoot(upperDir);
}
await tryUnmount(merged);
await rmWithRetryAsRoot(overlayRoot);
}
}

return overlay;
}

return {
path: lowerDir,
createOverlay,
[Symbol.asyncDispose]: async () => {
if (diagnosticOutput) {
await diskUsageRoot(root);
}
if (overlay) {
await overlay[Symbol.asyncDispose]();
}
await tryUnmount(root);
await rmWithRetryAsRoot(root);
},
}
}

async function retry(fn: (() => void) | (() => Promise<void>), retries: number, delayMs: number): Promise<void> {
for (let i = 0; i < retries; i++) {
try {
await fn();
return;
} catch (e) {
if (i === retries - 1) {
throw e;
}
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
}

async function tryUnmount(p: string) {
try {
await execAsync(processCwd, `sudo umount -R ${p}`)
} catch {
// ignore
}
}

function diskUsageRoot(p: string) {
return execAsync(processCwd, `sudo du -sh ${p}`);
}

function rmWithRetry(p: string) {
return retry(() => execAsync(processCwd, `rm -rf ${p}`), 3, 1000);
}

function rmWithRetryAsRoot(p: string) {
return retry(() => execAsync(processCwd, `sudo rm -rf ${p}`), 3, 1000);
}

function mkdirAll(...args: string[]) {
return execAsync(processCwd, `mkdir -p ${args.join(" ")}`);
}

function mkdirAllAsRoot(...args: string[]) {
return execAsync(processCwd, `sudo mkdir -p ${args.join(" ")}`);
}

/**
* Creates a fake overlay FS, which is just a directory on the local filesystem.
* Overlays are created by copying the contents of the `base` directory.
*/
export async function createCopyingOverlayFS(root: string, _diagnosticOutput: boolean): Promise<DisposableOverlayBaseFS> {
await rmWithRetry(root);
await mkdirAll(root);

const basePath = path.join(root, "base");
await mkdirAll(basePath);

let overlay: OverlayMergedFS | undefined;

async function createOverlay(): Promise<OverlayMergedFS> {
if (overlay) {
throw new Error("Overlay has already been created");
}

const overlayRoot = path.join(root, "overlay");
await rmWithRetry(overlayRoot);

await execAsync(processCwd, `cp -r --reflink=auto ${basePath} ${overlayRoot}`);

overlay = {
path: overlayRoot,
[Symbol.asyncDispose]: async () => {
overlay = undefined;
await rmWithRetry(overlayRoot);
}
}

return overlay;
}

return {
path: basePath,
createOverlay,
[Symbol.asyncDispose]: async () => {
if (overlay) {
await overlay[Symbol.asyncDispose]();
overlay = undefined;
}
await rmWithRetry(root);
},
}
}
3 changes: 2 additions & 1 deletion test/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { getTscRepoResult, downloadTsRepoAsync } from '../src/main'
import { execSync } from "child_process"
import { existsSync, mkdirSync } from "fs"
import path = require("path")
import { createCopyingOverlayFS } from '../src/utils/overlayFS'
describe("main", () => {
jest.setTimeout(10 * 60 * 1000)
xit("build-only correctly caches", async () => {
Expand All @@ -15,7 +16,7 @@ describe("main", () => {
path.resolve("./typescript-main/built/local/tsc.js"),
path.resolve("./typescript-44585/built/local/tsc.js"),
/*ignoreOldTscFailures*/ true, // as in a user test
"./ts_downloads",
await createCopyingOverlayFS("./ts_downloads", false),
/*diagnosticOutput*/ false)
expect(status).toEqual("NewBuildHadErrors")
expect(summary).toBeDefined()
Expand Down

0 comments on commit 3b3b4da

Please sign in to comment.