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
9 changes: 5 additions & 4 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -737,14 +737,15 @@ export default defineConfig([
},
},
{
// Workflow/action/runtime sources are plain JS evaluated outside the TS
// program (QuickJS, skill assets, or generated child-process wrappers), so
// type-aware rules cannot apply. Lint them with core untyped rules so typos
// and dead helpers fail loudly instead of becoming silent sandbox globals.
// Workflow/action/runtime and script helper sources are plain JS evaluated outside the TS
// program (QuickJS, skill assets, generated child-process wrappers, or local tooling), so
// type-aware rules cannot apply. Lint them with core untyped rules so typos and dead helpers
// fail loudly instead of becoming silent globals.
files: [
"src/node/builtinSkills/**/*.js",
"src/node/builtinWorkflowActions/**/*.js",
"src/node/workflowRuntime/*.js",
"scripts/lib/*.js",
],
extends: [tseslint.configs.disableTypeChecked],
languageOptions: {
Expand Down
2 changes: 1 addition & 1 deletion fmt.mk
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
.PHONY: fmt fmt-check fmt-prettier fmt-prettier-check fmt-shell fmt-shell-check fmt-nix fmt-nix-check fmt-python fmt-python-check fmt-sync-docs fmt-sync-docs-check update-flake-hash flake-hash-check

# Centralized patterns - single source of truth
PRETTIER_PATTERNS := 'src/**/*.{ts,tsx,json}' 'src/node/workflowRuntime/*.js' 'tests/**/*.ts' 'docs/**/*.mdx' 'package.json' 'tsconfig*.json' 'README.md'
PRETTIER_PATTERNS := 'src/**/*.{ts,tsx,json}' 'src/node/workflowRuntime/*.js' 'scripts/lib/*.js' 'tests/**/*.ts' 'docs/**/*.mdx' 'package.json' 'tsconfig*.json' 'README.md'
SHELL_SCRIPTS := scripts
PYTHON_DIRS := benchmarks

Expand Down
19 changes: 2 additions & 17 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,6 @@
const os = require("node:os");
const { workerBudgetFor } = require("./scripts/lib/worker_budget.js");

// Use cgroup-aware memory when available (containers), fall back to host RAM.
// process.constrainedMemory() returns the cgroup v2 limit (Node 19.6+),
// or 0/undefined outside a cgroup.
const totalMemoryBytes =
(typeof process.constrainedMemory === "function" &&
process.constrainedMemory()) ||
os.totalmem();

const cpuWorkerCap = Math.max(1, Math.floor(os.cpus().length * 0.5));
const memoryWorkerCap = Math.floor(
totalMemoryBytes / (1024 * 1024 * 1024) / 1.5,
);
const maxWorkers = Math.max(1, Math.min(cpuWorkerCap, memoryWorkerCap));
const maxWorkers = workerBudgetFor("jest");

/** @type {import('jest').Config} */
module.exports = {
Expand Down Expand Up @@ -55,9 +43,6 @@ module.exports = {
// This is slower but ensures compatibility
"node_modules/(?!\\.pnpm)(?!.*)",
],
// High core-count containers with limited cgroup memory (for example 96 cores /
// 32 GB) can OOM if Jest uses CPU-only parallelism, so keep roughly 1.5 GB
// per worker.
maxWorkers,
// Force exit after tests complete to avoid hanging on lingering handles
forceExit: true,
Expand Down
233 changes: 233 additions & 0 deletions scripts/lib/worker_budget.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
"use strict";

// Size tool worker pools against the tightest cgroup memory cap, including ancestor caps that
// Node's leaf-only constrainedMemory() check can miss.

const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");

const BYTES_PER_GIB = 1024 ** 3;
const DEFAULT_CGROUP_ROOT = "/sys/fs/cgroup";
const DEFAULT_PROC_SELF_CGROUP = "/proc/self/cgroup";

// cgroup v1 spells "unlimited" as a saturated integer rather than "max", so treat implausibly large
// caps as absent instead of trusting them.
const UNLIMITED_BYTES_FLOOR = 2n ** 62n;

// Peak RSS measured per worker in this repo, rounded up for growth: ESLint's --concurrency lanes are
// worker threads in one process (~2.8GiB per lane), while Jest forks reach ~4.7GiB each.
const PROFILES = {
eslint: { memoryPerWorkerGib: 4, maxWorkers: 4 },
jest: { memoryPerWorkerGib: 6, maxWorkers: 4 },
};

const CPU_FRACTION = 0.5;

function readFileOrNull(filePath) {
try {
return fs.readFileSync(filePath, "utf8");
} catch {
return null;
}
}

function parseBytes(raw) {
if (raw == null) {
return null;
}
const text = raw.trim();
if (text === "") {
return null;
}
let value;
try {
value = BigInt(text);
} catch {
return null;
}
if (value < 0n) {
return null;
}
return value;
}

function parseLimitBytes(raw) {
const value = parseBytes(raw);
if (value == null || value === 0n || value >= UNLIMITED_BYTES_FLOOR) {
return null;
}
return Number(value);
}

function dirChain(root, cgroupPath) {
const segments = cgroupPath.split("/").filter(Boolean);
const chain = [];
for (let depth = segments.length; depth >= 0; depth--) {
chain.push(path.join(root, ...segments.slice(0, depth)));
}
return chain;
}

function cgroupMembership(options) {
const raw = readFileOrNull(options.procSelfCgroup ?? DEFAULT_PROC_SELF_CGROUP);
let v2Path = null;
let v1MemoryPath = null;

for (const line of raw?.split("\n") ?? []) {
const match = line.trim().match(/^\d+:([^:]*):(.*)$/);
if (match == null) {
continue;
}
const controllers = match[1].split(",");
if (controllers.length === 1 && controllers[0] === "") {
v2Path = match[2];
} else if (controllers.includes("memory")) {
v1MemoryPath = match[2];
}
}

return { v2Path, v1MemoryPath };
}

// Keep the constraining directory so its ancestor-level co-tenant usage can be subtracted.
function tightestConstraint(dirs, limitFile) {
let constraint = null;
for (const dir of dirs) {
const limitBytes = parseLimitBytes(readFileOrNull(path.join(dir, limitFile)));
if (limitBytes != null && (constraint == null || limitBytes <= constraint.limitBytes)) {
constraint = { dir, limitBytes };
}
}
return constraint;
}

function resolveMemoryConstraint(options = {}) {
const cgroupRoot = options.cgroupRoot ?? DEFAULT_CGROUP_ROOT;
const membership = cgroupMembership(options);
if (membership.v2Path != null) {
const constraint = tightestConstraint(dirChain(cgroupRoot, membership.v2Path), "memory.max");
if (constraint != null) {
return constraint;
}
}

if (membership.v1MemoryPath == null) {
return null;
}
return tightestConstraint(
dirChain(path.join(cgroupRoot, "memory"), membership.v1MemoryPath),
"memory.limit_in_bytes"
);
}

function parseMemoryStat(dir) {
const raw = readFileOrNull(path.join(dir, "memory.stat"));
if (raw == null) {
return null;
}

const values = new Map();
for (const line of raw.split("\n")) {
const [key, value] = line.trim().split(/\s+/);
const parsed = parseBytes(value);
if (key && parsed != null) {
values.set(key, Number(parsed));
}
}
return values;
}

// Discount inactive file cache, but use the v2 resident-memory fields as a floor when available.
function readCgroupUsageBytes(dir) {
const stat = parseMemoryStat(dir);
const current = parseBytes(readFileOrNull(path.join(dir, "memory.current")));
if (current != null) {
if (stat == null) {
return Number(current);
}
const resident =
(stat.get("anon") ?? 0) +
(stat.get("kernel") ?? stat.get("slab") ?? 0) +
(stat.get("shmem") ?? 0);
const inactiveFile = stat.get("inactive_file");
return inactiveFile == null ? resident : Math.max(resident, Number(current) - inactiveFile);
}

const usage = parseBytes(readFileOrNull(path.join(dir, "memory.usage_in_bytes")));
if (usage == null) {
return null;
}
const inactiveFile = stat?.get("total_inactive_file") ?? stat?.get("inactive_file");
return inactiveFile == null ? Number(usage) : Math.max(0, Number(usage) - inactiveFile);
}

function computeWorkers(input) {
const cpuWorkers = Math.max(1, Math.floor(input.cpuCount * CPU_FRACTION));

// Preserve headroom for the parent process, active file cache, and co-tenants growing mid-run.
const reserveBytes = Math.max(2 * BYTES_PER_GIB, input.limitBytes * 0.15);
const usableBytes = Math.max(0, input.limitBytes - input.inUseBytes - reserveBytes);
const memoryWorkers = Math.floor(usableBytes / (input.memoryPerWorkerGib * BYTES_PER_GIB));

return Math.max(1, Math.min(cpuWorkers, memoryWorkers, input.maxWorkers));
}

function resolveWorkerBudget(profileName, options = {}) {
const profile = PROFILES[profileName];
if (profile == null) {
throw new Error(
`unknown worker budget profile "${profileName}" (expected one of: ${Object.keys(PROFILES).join(", ")})`
);
}

const constraint = resolveMemoryConstraint(options);
// Without a cgroup cap there is no bounded co-tenant usage signal. Host free-memory swings would
// make a busy laptop silently serialize its own test run.
const limitBytes = constraint?.limitBytes ?? os.totalmem();
const inUseBytes = constraint == null ? 0 : (readCgroupUsageBytes(constraint.dir) ?? 0);

const input = {
...profile,
cpuCount: os.availableParallelism?.() ?? os.cpus().length,
limitBytes,
inUseBytes,
};
return { ...input, cgroupDir: constraint?.dir ?? null, workers: computeWorkers(input) };
}

function formatWorkerBudget(budget) {
const gib = (bytes) => `${(bytes / BYTES_PER_GIB).toFixed(1)}GiB`;
return [
`workers=${budget.workers}`,
`limit=${gib(budget.limitBytes)}`,
`inUse=${gib(budget.inUseBytes)}`,
`perWorker=${budget.memoryPerWorkerGib}GiB`,
`cpus=${budget.cpuCount}`,
`cgroup=${budget.cgroupDir ?? "none"}`,
].join(" ");
}

function workerBudgetFor(profileName) {
const budget = resolveWorkerBudget(profileName);
if (process.env.MUX_WORKER_BUDGET_DEBUG) {
process.stderr.write(`[worker-budget] ${profileName} ${formatWorkerBudget(budget)}\n`);
}
return budget.workers;
}

module.exports = {
computeWorkers,
readCgroupUsageBytes,
resolveMemoryConstraint,
workerBudgetFor,
};

if (require.main === module) {
const profileName = process.argv[2];
const budget = resolveWorkerBudget(profileName);
if (process.argv.includes("--debug") || process.env.MUX_WORKER_BUDGET_DEBUG) {
process.stderr.write(`[worker-budget] ${profileName} ${formatWorkerBudget(budget)}\n`);
}
process.stdout.write(`${budget.workers}\n`);
}
45 changes: 9 additions & 36 deletions scripts/lint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -29,34 +29,10 @@ ESLINT_PATTERNS=(
'src/**/*.{ts,tsx}'
'src/node/builtinSkills/**/*.js'
'src/node/workflowRuntime/*.js'
'scripts/lib/*.js'
)

get_cpu_count() {
local cpu_count=""

if command -v getconf >/dev/null 2>&1; then
cpu_count="$(getconf _NPROCESSORS_ONLN 2>/dev/null || true)"
fi

if [ -z "$cpu_count" ] && command -v nproc >/dev/null 2>&1; then
cpu_count="$(nproc 2>/dev/null || true)"
fi

if [ -z "$cpu_count" ] && command -v sysctl >/dev/null 2>&1; then
cpu_count="$(sysctl -n hw.ncpu 2>/dev/null || true)"
fi

if [[ "$cpu_count" =~ ^[0-9]+$ ]] && [ "$cpu_count" -gt 0 ]; then
echo "$cpu_count"
else
echo 2
fi
}

get_default_eslint_concurrency() {
local cpu_count
local concurrency

# Most local `make static-check` runs are warm-cache validation after a small
# edit. ESLint's worker startup/merge overhead dominates that path, so keep it
# single-process once the cache exists; cold caches still scale up for CI-like
Expand All @@ -66,18 +42,15 @@ get_default_eslint_concurrency() {
return
fi

cpu_count="$(get_cpu_count)"
concurrency=$(((cpu_count + 1) / 2))

# User rationale: local static-check should scale up on agent/desktop machines
# without letting ESLint's auto concurrency spawn one worker per core.
if [ "$concurrency" -lt 2 ]; then
concurrency=2
elif [ "$concurrency" -gt 8 ]; then
concurrency=8
# Cold type-aware runs scale memory with concurrency, so use cgroup headroom instead of visible
# core count. Keep stderr attached for diagnostics, and fall back only if the helper fails.
local concurrency
if concurrency="$(node "$SCRIPT_DIR/lib/worker_budget.js" eslint)" \
&& [[ "$concurrency" =~ ^[0-9]+$ ]] && [ "$concurrency" -gt 0 ]; then
echo "$concurrency"
else
echo 1
fi

echo "$concurrency"
}

ESLINT_CONCURRENCY="${MUX_ESLINT_CONCURRENCY:-$(get_default_eslint_concurrency)}"
Expand Down
Loading
Loading