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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ Versioning](https://semver.org/spec/v2.0.0.html).
- GitHub Actions caching now uses `http` or `https` based on the scheme of
`ACTIONS_RESULTS_URL`. Always calling `https.request` broke `http://` cache
proxies used by some third-party runners.
- `WIREIT_PARALLEL`, `WIREIT_WATCH_POLL_MS`, and `WIREIT_MAX_OPEN_FILES` now
reject any value that is not a positive integer, such as `2abc` or `1.5`.

### Changed

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -890,11 +890,11 @@ The following environment variables affect the behavior of Wireit:
| `WIREIT_LOGGER` | How to present progress and results on the command line.<br><br>Options:<br><ul><li>`quiet` (default for normal execution): Writes a single dynamically updating line summarizing progress. Only passes along stdout and stderr from commands if there's a failure, or if the command is a service.</li><li>`quiet-ci` (default when `env.CI` or `!stdout.isTTY`): like `quiet` but optimized for non-interactive environments, like GitHub Actions runners.</li><li>`simple`: A verbose logger that presents clear information about the work that Wireit is doing.</li><li>`metrics`: Like `simple`, but also presents a summary table of results once a command is finished.</li></ul> |
| `WIREIT_CACHE_MAX_ENTRIES` | Maximum number of [local cache](#local-caching) entries to retain per script.<br><br>Defaults to `10`.<br><br>Must be a positive integer or `infinity`. |
| `WIREIT_DEBUG_LOG_FILE` | Path to a file which will receive detailed event logging. |
| `WIREIT_MAX_OPEN_FILES` | Limits the number of file descriptors Wireit will have open concurrently. Prevents resource exhaustion when checking large numbers of cached files. Set to a lower number if you hit file descriptor limits. |
| `WIREIT_MAX_OPEN_FILES` | Limits the number of file descriptors Wireit will have open concurrently. Prevents resource exhaustion when checking large numbers of cached files. Set to a lower number if you hit file descriptor limits.<br><br>Defaults to `200`.<br><br>Must be a positive integer. |
| `WIREIT_PARALLEL` | [Maximum number of scripts to run at one time](#parallelism).<br><br>Defaults to 2×logical CPU cores.<br><br>Must be a positive integer or `infinity`. |
| `WIREIT_WATCH` | Set to `true` to enable [watch mode](#watch-mode). |
| `WIREIT_WATCH_STRATEGY` | How Wireit determines when a file has changed which warrants a new watch iteration.<br><br>Options:<br><ul><li>`event` (default): Register OS file system watcher callbacks (using [chokidar](https://github.com/paulmillr/chokidar)).</li><li>`poll`: Poll the filesystem every `WIREIT_WATCH_POLL_MS` milliseconds. Less responsive and worse performance than `event`, but a good fallback for when `event` does not work well or at all (e.g. filesystems that don't support filesystem events, or performance and memory problems with large file trees).</li></ul> |
| `WIREIT_WATCH_POLL_MS` | When `WIREIT_WATCH_STRATEGY` is `poll`, how many milliseconds to wait between each filesystem poll. Defaults to `500`. |
| `WIREIT_WATCH_POLL_MS` | When `WIREIT_WATCH_STRATEGY` is `poll`, how many milliseconds to wait between each filesystem poll. Defaults to `500`.<br><br>Must be a positive integer. |
| `CI` | Affects the default value of `WIREIT_CACHE`.<br><br>Automatically set to `true` by [GitHub Actions](https://docs.github.com/en/actions/learn-github-actions/environment-variables#default-environment-variables) and most other CI (continuous integration) services.<br><br>Must be exactly `true`. If unset or any other value, interpreted as `false`. |

### Glob patterns
Expand Down
49 changes: 30 additions & 19 deletions src/cli-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {Console, Logger} from './logging/logger.js';
import {MetricsLogger} from './logging/metrics-logger.js';
import {QuietCiLogger, QuietLogger} from './logging/quiet-logger.js';
import * as fs from './util/fs.js';
import {parsePositiveInteger} from './util/parse-positive-integer.js';
import {unreachable} from './util/unreachable.js';

export const packageDir = await (async (): Promise<string | undefined> => {
Expand Down Expand Up @@ -112,8 +113,8 @@ export const getOptions = async (): Promise<Result<Options>> => {
if (workerString == null || workerString === '') {
return {ok: true, value: defaultValue};
}
const parsedInt = parseInt(workerString, 10);
if (Number.isNaN(parsedInt) || parsedInt <= 0) {
const parsedInt = parsePositiveInteger(workerString);
if (parsedInt === undefined) {
return {
ok: false,
error: {
Expand Down Expand Up @@ -178,8 +179,8 @@ export const getOptions = async (): Promise<Result<Options>> => {
if (str.match(/^infinity$/i)) {
return {ok: true, value: Infinity};
}
const parsedInt = parseInt(str, 10);
if (Number.isNaN(parsedInt) || parsedInt <= 0) {
const parsedInt = parsePositiveInteger(str);
if (parsedInt === undefined) {
return {
ok: false,
error: {
Expand All @@ -198,6 +199,27 @@ export const getOptions = async (): Promise<Result<Options>> => {
return cacheMaxEntriesResult;
}

// These are read elsewhere, which falls back to the default for an invalid
// value: WIREIT_MAX_OPEN_FILES when util/fs.ts loads, before this runs, and
// WIREIT_WATCH_POLL_MS only in watch mode. Checking them here makes an
// invalid value fail like the other integer options.
for (const name of ['WIREIT_MAX_OPEN_FILES', 'WIREIT_WATCH_POLL_MS']) {
const str = process.env[name] ?? '';
if (str !== '' && parsePositiveInteger(str) === undefined) {
return {
ok: false,
error: {
reason: 'invalid-usage',
message:
`Expected the ${name} env variable to be ` +
`a positive integer, got ${JSON.stringify(str)}`,
script,
type: 'failure',
},
};
}
}

const failureModeResult = ((): Result<FailureMode> => {
const str = process.env['WIREIT_FAILURES'];
if (!str) {
Expand Down Expand Up @@ -520,23 +542,12 @@ function readWatchConfigFromEnv(): Options['watch'] {
return DEFAULT_WATCH_STRATEGY;
}
case 'poll': {
let interval = DEFAULT_WATCH_INTERVAL;
const intervalStr = process.env['WIREIT_WATCH_POLL_MS'];
if (intervalStr) {
const parsed = Number(intervalStr);
if (Number.isNaN(parsed) || parsed <= 0) {
console.error(
`⚠️ Expected WIREIT_WATCH_POLL_MS to be a positive integer, ` +
`got ${JSON.stringify(intervalStr)}. Using default interval of ` +
`${DEFAULT_WATCH_INTERVAL}ms.`,
);
} else {
interval = parsed;
}
}
return {
strategy: 'poll',
interval,
// getOptions has already failed for an invalid value.
interval:
parsePositiveInteger(process.env['WIREIT_WATCH_POLL_MS'] ?? '') ??
DEFAULT_WATCH_INTERVAL,
};
}
default: {
Expand Down
60 changes: 41 additions & 19 deletions src/test/cli-options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,28 @@ const commands: AgentCommands[] = [
},
];

const positiveIntegerEnvTests: Array<{
name: string;
env: Record<string, string>;
expected: Pick<Options, 'numWorkers'> | Pick<Options, 'cacheMaxEntries'>;
}> = [
{
name: 'WIREIT_PARALLEL=" +02 "',
env: {WIREIT_PARALLEL: ' +02 '},
expected: {numWorkers: 2},
},
{
name: 'WIREIT_CACHE_MAX_ENTRIES=" +02 "',
env: {WIREIT_CACHE_MAX_ENTRIES: ' +02 '},
expected: {cacheMaxEntries: 2},
},
{
name: 'WIREIT_CACHE_MAX_ENTRIES=5',
env: {WIREIT_CACHE_MAX_ENTRIES: '5'},
expected: {cacheMaxEntries: 5},
},
];

for (const {agent, runCmd, testCmd, startCmd, needsExtraDashes} of commands) {
if (agent === 'nodeRun' && NODE_MAJOR_VERSION < 22) {
// node --run was added in Node 22.
Expand Down Expand Up @@ -565,24 +587,24 @@ for (const {agent, runCmd, testCmd, startCmd, needsExtraDashes} of commands) {
}),
);

void test(
`${agent} WIREIT_CACHE_MAX_ENTRIES=5`,
rigTest(async ({rig}) => {
await assertOptions(
rig,
`${runCmd} main ${extraDashes}`,
{
agent,
script: {
packageDir: rig.temp,
name: 'main',
for (const {name, env, expected} of positiveIntegerEnvTests) {
void test(
`${agent} ${name}`,
rigTest(async ({rig}) => {
await assertOptions(
rig,
`${runCmd} main ${extraDashes}`,
{
agent,
script: {
packageDir: rig.temp,
name: 'main',
},
...expected,
},
cacheMaxEntries: 5,
},
{
WIREIT_CACHE_MAX_ENTRIES: '5',
},
);
}),
);
env,
);
}),
);
}
}
104 changes: 104 additions & 0 deletions src/test/errors-usage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,110 @@ void test(
}),
);

void test(
'partially numeric parallelism',
rigTest(async ({rig}) => {
await rig.write({
'package.json': {
scripts: {
main: 'wireit',
},
wireit: {
main: {command: 'node --version'},
},
},
});
const result = rig.exec('npm run main', {
env: {WIREIT_PARALLEL: '2abc'},
});
const done = await result.exit;
assert.equal(done.code, 1);
assert.ok(
done.stderr.includes(
`❌ [main] Invalid usage: Expected the WIREIT_PARALLEL env variable to be a positive integer, got "2abc"`,
),
);
}),
);

void test(
'partially numeric WIREIT_CACHE_MAX_ENTRIES',
rigTest(async ({rig}) => {
await rig.write({
'package.json': {
scripts: {
main: 'wireit',
},
wireit: {
main: {command: 'node --version'},
},
},
});
const result = rig.exec('npm run main', {
env: {WIREIT_CACHE_MAX_ENTRIES: '2abc'},
});
const done = await result.exit;
assert.equal(done.code, 1);
assert.ok(
done.stderr.includes(
`❌ [main] Invalid usage: Expected the WIREIT_CACHE_MAX_ENTRIES env variable to be a positive integer or "infinity", got "2abc"`,
),
);
}),
);

void test(
'fractional WIREIT_MAX_OPEN_FILES',
rigTest(async ({rig}) => {
await rig.write({
'package.json': {
scripts: {
main: 'wireit',
},
wireit: {
main: {command: 'node --version'},
},
},
});
const result = rig.exec('npm run main', {
env: {WIREIT_MAX_OPEN_FILES: '1.5'},
});
const done = await result.exit;
assert.equal(done.code, 1);
assert.ok(
done.stderr.includes(
`❌ [main] Invalid usage: Expected the WIREIT_MAX_OPEN_FILES env variable to be a positive integer, got "1.5"`,
),
);
}),
);

void test(
'fractional WIREIT_WATCH_POLL_MS',
rigTest(async ({rig}) => {
await rig.write({
'package.json': {
scripts: {
main: 'wireit',
},
wireit: {
main: {command: 'node --version'},
},
},
});
const result = rig.exec('npm run main', {
env: {WIREIT_WATCH_POLL_MS: '1.5'},
});
const done = await result.exit;
assert.equal(done.code, 1);
assert.ok(
done.stderr.includes(
`❌ [main] Invalid usage: Expected the WIREIT_WATCH_POLL_MS env variable to be a positive integer, got "1.5"`,
),
);
}),
);

void test(
'nonsense WIREIT_CACHE',
rigTest(async ({rig}) => {
Expand Down
27 changes: 15 additions & 12 deletions src/util/fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from 'fs';
import {Deferred} from './deferred.js';
import './dispose.js';
import {parsePositiveInteger} from './parse-positive-integer.js';
export {constants} from 'fs';

declare global {
Expand Down Expand Up @@ -57,18 +58,20 @@ export class Semaphore {
}
}

export const fileBudget = (() => {
let maxOpenFiles = Number(process.env['WIREIT_MAX_OPEN_FILES']);
if (isNaN(maxOpenFiles)) {
// This is tricky to get right. There's no simple cross-platform way to
// determine what our current limits are. Windows it's 512, on macOS it
// defaults to 256, and on Linux it varies a lot.
// 200 gives us a bit of headroom for other things that might be using
// file descriptors in our process, like node internals.
maxOpenFiles = 200;
}
return new Semaphore(maxOpenFiles);
})();
/**
* This is tricky to get right. There's no simple cross-platform way to
* determine what our current limits are. Windows it's 512, on macOS it
* defaults to 256, and on Linux it varies a lot.
* 200 gives us a bit of headroom for other things that might be using
* file descriptors in our process, like node internals.
*/
const DEFAULT_MAX_OPEN_FILES = 200;

export const fileBudget = new Semaphore(
// getOptions reports an invalid value as an error before any script runs.
parsePositiveInteger(process.env['WIREIT_MAX_OPEN_FILES'] ?? '') ??
DEFAULT_MAX_OPEN_FILES,
);

export async function mkdir(
path: string,
Expand Down
23 changes: 23 additions & 0 deletions src/util/parse-positive-integer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

/**
* Parse a positive integer from an environment variable, or return undefined
* for anything else.
*
* Surrounding whitespace, a leading "+", and leading zeroes are accepted,
* because parseInt accepted them. Trailing characters, fractions, exponents,
* and integers too large to represent exactly are rejected, where parseInt or
* Number would have silently read a different value.
*/
export function parsePositiveInteger(value: string): number | undefined {
const normalized = value.trim();
if (!/^\+?\d+$/.test(normalized)) {
return undefined;
}
const parsed = Number(normalized);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
}
Loading