Skip to content
Open
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
156 changes: 156 additions & 0 deletions spec/util-walk.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { existsSync } from 'node:fs';

/**
* `walk` opens a FD on every single file, recursively, in the directory it walks.
* this makes it really easy to hit EMFILE for very large folders.
* This test asserts that we never hold more files than a fixed limit.
*/
const binaryCheck = vi.hoisted(() => ({
openFiles: 0,
peakOpenFiles: 0,
calls: 0,
}));

vi.mock('isbinaryfile', () => ({
isBinaryFile: async (filePath: string) => {
binaryCheck.openFiles += 1;
binaryCheck.calls += 1;
binaryCheck.peakOpenFiles = Math.max(binaryCheck.peakOpenFiles, binaryCheck.openFiles);

// Hold the FD for at least one tick of the event loop
await new Promise((resolve) => setTimeout(resolve, 1));
binaryCheck.openFiles -= 1;
return path.extname(filePath) === '.bin';
},
}));

const {
PromiseParallelismLimiter: ConcurrencyLimiter,
walk,
MAX_OPEN_FILE_DESCRIPTORS,
} = await import('../src/util.js');

let testWorkingDir: string;

beforeEach(async () => {
binaryCheck.openFiles = 0;
binaryCheck.peakOpenFiles = 0;
binaryCheck.calls = 0;
testWorkingDir = await mkdtemp(path.join(os.tmpdir(), 'util-walk-test-workdir-'));
});

// cleanup tmp working dirs after each test is done
afterEach(async () => {
await rm(testWorkingDir, { recursive: true, force: true });
});

async function writeFiles(dirPath: string, names: string[]) {
await mkdir(dirPath, { recursive: true });

Check failure on line 53 in spec/util-walk.spec.ts

View workflow job for this annotation

GitHub Actions / Test (22.12.x)

spec/util-walk.spec.ts > walk > doesn't deadlock on deep tree

Error: ENAMETOOLONG: name too long, mkdir '/var/folders/df/djsxfhc17x95674wsm_g8s980000gn/T/util-walk-test-workdir-hDSZHf/level-0/level-1/level-2/level-3/level-4/level-5/level-6/level-7/level-8/level-9/level-10/level-11/level-12/level-13/level-14/level-15/level-16/level-17/level-18/level-19/level-20/level-21/level-22/level-23/level-24/level-25/level-26/level-27/level-28/level-29/level-30/level-31/level-32/level-33/level-34/level-35/level-36/level-37/level-38/level-39/level-40/level-41/level-42/level-43/level-44/level-45/level-46/level-47/level-48/level-49/level-50/level-51/level-52/level-53/level-54/level-55/level-56/level-57/level-58/level-59/level-60/level-61/level-62/level-63/level-64/level-65/level-66/level-67/level-68/level-69/level-70/level-71/level-72/level-73/level-74/level-75/level-76/level-77/level-78/level-79/level-80/level-81/level-82/level-83/level-84/level-85/level-86/level-87/level-88/level-89/level-90/level-91/level-92/level-93/level-94/level-95/level-96/level-97/level-98/level-99/level-100/level-101/level-102/level-103/level-104/level-105/level-106/level-107/level-108/level-109/level-110/level-111/level-112/level-113/level-114/level-115/level-116/level-117/level-118/level-119/level-120/level-121/level-122/level-123/level-124/level-125/level-126/level-127/level-128/level-129/level-130/level-131/level-132/level-133/level-134/level-135/level-136/level-137/level-138/level-139/level-140/level-141/level-142/level-143/level-144/level-145/level-146/level-147/level-148/level-149' ❯ writeFiles spec/util-walk.spec.ts:53:3 ❯ spec/util-walk.spec.ts:128:5 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { errno: -63, code: 'ENAMETOOLONG', syscall: 'mkdir', path: '/var/folders/df/djsxfhc17x95674wsm_g8s980000gn/T/util-walk-test-workdir-hDSZHf/level-0/level-1/level-2/level-3/level-4/level-5/level-6/level-7/level-8/level-9/level-10/level-11/level-12/level-13/level-14/level-15/level-16/level-17/level-18/level-19/level-20/level-21/level-22/level-23/level-24/level-25/level-26/level-27/level-28/level-29/level-30/level-31/level-32/level-33/level-34/level-35/level-36/level-37/level-38/level-39/level-40/level-41/level-42/level-43/level-44/level-45/level-46/level-47/level-48/level-49/level-50/level-51/level-52/level-53/level-54/level-55/level-56/level-57/level-58/level-59/level-60/level-61/level-62/level-63/level-64/level-65/level-66/level-67/level-68/level-69/level-70/level-71/level-72/level-73/level-74/level-75/level-76/level-77/level-78/level-79/level-80/level-81/level-82/level-83/level-84/level-85/level-86/level-87/level-88/level-89/level-90/level-91/level-92/level-93/level-94/level-95/level-96/level-97/level-98/level-99/level-100/level-101/level-102/level-103/level-104/level-105/level-106/level-107/level-108/level-109/level-110/level-111/level-112/level-113/level-114/level-115/level-116/level-117/level-118/level-119/level-120/level-121/level-122/level-123/level-124/level-125/level-126/level-127/level-128/level-129/level-130/level-131/level-132/level-133/level-134/level-135/level-136/level-137/level-138/level-139/level-140/level-141/level-142/level-143/level-144/level-145/level-146/level-147/level-148/level-149' }
for (const name of names) {
await writeFile(path.join(dirPath, name), 'x');
}
}

describe('ConcurrencyLimiter', () => {
it('limits promise parallelism to its passed limit', async () => {
const limiter = new ConcurrencyLimiter(3);
let runningPromises = 0;
let peakRunningPromises = 0;

await Promise.all(
Array.from({ length: 50 }, () =>
limiter.run(async () => {
runningPromises += 1;
peakRunningPromises = Math.max(peakRunningPromises, runningPromises);
await new Promise((resolve) => setTimeout(resolve, 1));
runningPromises -= 1;
}),
),
);

expect(peakRunningPromises).toBe(3);

Check failure on line 76 in spec/util-walk.spec.ts

View workflow job for this annotation

GitHub Actions / Test (22.12.x)

spec/util-walk.spec.ts > ConcurrencyLimiter > limits promise parallelism to its passed limit

AssertionError: expected 50 to be 3 // Object.is equality - Expected + Received - 3 + 50 ❯ spec/util-walk.spec.ts:76:33
expect(runningPromises).toBe(0);
});

it('relases a parallelism slot if as running promises rejects', async () => {
const limiter = new ConcurrencyLimiter(1);

const rejectMessage = 'promise rejected';
await expect(limiter.run(() => Promise.reject(new Error(rejectMessage)))).rejects.toThrow(
rejectMessage,
);

const result = 'continues to run after one promise in the queue rejected';

await expect(limiter.run(async () => result)).resolves.toBe(result);
});
});

describe('walk', () => {
it('has no more than 100 files open at the same time', async () => {
const fileCount = 750;
await writeFiles(
path.join(testWorkingDir, 'Resources'),
Array.from({ length: fileCount }, (_unused, index) => `file-${index}.bin`),
);

const result = await walk(testWorkingDir);

expect(binaryCheck.calls).toBe(fileCount);
expect(binaryCheck.peakOpenFiles).toBeLessThanOrEqual(fileCount);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is vacuous — peak can never exceed the file count. The test title promises "no more than 100", so this should be toBeLessThanOrEqual(MAX_OPEN_FILE_DESCRIPTORS) (already imported above).

expect(result).toHaveLength(fileCount);
});

it('parallelizes reads up to the limit', async () => {
await writeFiles(
path.join(testWorkingDir, 'Resources'),
Array.from({ length: 750 }, (_unused, index) => `file-${index}.bin`),
);

await walk(testWorkingDir);

expect(binaryCheck.peakOpenFiles).toBe(750);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This asserts unlimited concurrency (750 concurrent reads of 750 files) — it only passes because the limiter is currently a no-op, and it will fail once the limiter works. With 750 files and a working limiter this should saturate, so expect(binaryCheck.peakOpenFiles).toBe(MAX_OPEN_FILE_DESCRIPTORS) matches the test's name.

});

it("doesn't deadlock on deep tree", async () => {
// since walking each directory awaits Promise.all(walk([its children])
// make sure going deep doesn't deadlock
const treeCDepth = MAX_OPEN_FILE_DESCRIPTORS + 50;
let deepPath = testWorkingDir;
for (let level = 0; level < treeCDepth; level += 1) {
deepPath = path.join(deepPath, `level-${level}`);
}
await writeFiles(deepPath, ['deep.bin']);

const result = await walk(testWorkingDir);

expect(result).toEqual([path.join(deepPath, 'deep.bin')]);
});

it("doesn't mess with result values or order", async () => {
const contents = path.join(testWorkingDir, 'Contents');
await writeFiles(path.join(contents, 'MacOS'), ['Foo.bin', 'readme.txt']);
await writeFiles(path.join(contents, 'Frameworks', 'Foo.framework'), ['Foo.bin']);
await writeFiles(path.join(contents, 'Frameworks', 'Foo Helper.app'), ['Helper.bin']);
await writeFiles(path.join(contents, 'Resources'), ['icon.icns.cstemp']);

const result = await walk(testWorkingDir);

expect(new Set(result)).toEqual(
new Set([
path.join(contents, 'MacOS', 'Foo.bin'),
path.join(contents, 'Frameworks', 'Foo.framework', 'Foo.bin'),
path.join(contents, 'Frameworks', 'Foo.framework'),
path.join(contents, 'Frameworks', 'Foo Helper.app', 'Helper.bin'),
path.join(contents, 'Frameworks', 'Foo Helper.app'),
]),
);

expect(existsSync(path.join(contents, 'Resources', 'icon.icns.cstemp'))).toBe(false);
});
});
58 changes: 54 additions & 4 deletions src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,43 @@ export async function execFileAsync(
type DeepListItem<T> = null | T | DeepListItem<T>[];
type DeepList<T> = DeepListItem<T>[];

/**
* A queue of promises, never running more than `limit` at the same time.
* @internal
*/
export class PromiseParallelismLimiter {
private numRunningPromsies = 0;
private readonly waitingPromises: (() => void)[] = [];

constructor(private maxRunningPromises: number) {
if (maxRunningPromises < 1) {
throw new Error('parallelism limit < 1 would never run anything.');
}
}

async run<T>(promise: () => Promise<T>): Promise<T> {
if(this.numRunningPromsies > this.maxRunningPromises) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

numRunningPromsies is never incremented, so this condition is never true and the limiter is a no-op — every task runs immediately (this is why the new unit test fails with peak 50). You need to increment after acquiring a slot, and the comparison needs to be >= (with >, limit+1 tasks can run). One more subtlety: decrementing and then waking a waiter lets a brand-new run() call steal the slot before the waiter resumes, transiently exceeding the limit. Something like this passes both of your unit tests:

async run<T>(task: () => Promise<T>): Promise<T> {
  if (this.numRunningPromises >= this.maxRunningPromises) {
    await new Promise<void>((resolve) => this.waitingPromises.push(resolve));
  }
  this.numRunningPromises++;
  try {
    return await task();
  } finally {
    this.numRunningPromises--;
    this.waitingPromises.shift()?.();
  }
}

await new Promise<void>((resolve) => {
this.waitingPromises.push(resolve);
});
}

try {
// we have to await, lest the finally fire instantly
return await promise();
} finally {
this.numRunningPromsies--

// grab the next promise in line.
// if we have space, run it right now.
const next = this.waitingPromises.shift();
if (next) {
next();
}
}
}
}

export function compactFlattenedList<T>(list: DeepList<T>): T[] {
const result: T[] = [];

Expand Down Expand Up @@ -127,6 +164,14 @@ export async function validateOptsPlatform(opts: BaseSignOptions): Promise<Elect
return await detectElectronPlatform(opts);
}

// The max number of file handles we're allowed to hold open at once
// `walk` opens every file in we want to sign to check if it's a binary.
// Every single open call holds a file descriptor. and we quickly hit ulimit,
// thus throwing an EMFILE, if you have a very lakge set of files to sign.
// 100 is a very safe number of files to hold open concurrently.
// It's higher than basically every ulimit.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inverted — 100 is safe because it's lower than basically every ulimit (macOS defaults to 256).

export const MAX_OPEN_FILE_DESCRIPTORS = 100;

/**
* This function returns a promise resolving all child paths within the directory specified.
*
Expand All @@ -137,21 +182,26 @@ export async function validateOptsPlatform(opts: BaseSignOptions): Promise<Elect
export async function walk(dirPath: string): Promise<string[]> {
debugLog('Walking... ' + dirPath);

// A directory waits for its children, so we make the limiter limit by
// file system operations, instead of redccursive _walkAsync calls,
// beacuse those could deadlock on deep directory trees.
const limiter = new PromiseParallelismLimiter(MAX_OPEN_FILE_DESCRIPTORS);

async function _walkAsync(dirPath: string): Promise<DeepList<string>> {
const children = await fs.promises.readdir(dirPath);
const children = await limiter.run(() => fs.promises.readdir(dirPath));
return await Promise.all(
children.map(async (child) => {
const filePath = path.resolve(dirPath, child);

const stat = await fs.promises.lstat(filePath);
const stat = await limiter.run(() => fs.promises.lstat(filePath));
if (stat.isFile()) {
switch (path.extname(filePath)) {
case '.cstemp': // Temporary file generated from past codesign
debugLog('Removing... ' + filePath);
await fs.promises.rm(filePath, { recursive: true, force: true });
await limiter.run(() => fs.promises.rm(filePath, { recursive: true, force: true }));
return null;
default:
return await getFilePathIfBinary(filePath);
return await limiter.run(() => getFilePathIfBinary(filePath));
}
} else if (stat.isDirectory() && !stat.isSymbolicLink()) {
const walkResult = await _walkAsync(filePath);
Expand Down
Loading