fix(#4949): cap unmeasured files per Windows conformance chunk (#4950)

* fix(#4949): cap unmeasured files per Windows conformance chunk

The windows conformance CI lane keeps going red every few updates:
scripts/run-tests.cjs kills a chunk at the 600s per-chunk backstop with
zero failing tests, pure slowness. Both recent incidents (ccfed63355,
af822a8024) killed a chunk holding 5-6 files absent from
tests/test-timings.json, packed alongside the chunk's measured files —
each unmeasured file's guessed weight looked affordable alone, but
several guesses compounded into a real overrun no single file's weight
predicted. tests/test-timings.json is manually regenerated and
routinely stale (30.5% of the conformance-tier pool is currently
unmeasured), so this keeps recurring as new conformance-tier files are
added.

Add a third, independent per-chunk budget to packChunks: a cap on how
many unmeasured files any one chunk may hold (win32-only, default 2,
RUN_TESTS_MAX_UNMEASURED_PER_CHUNK-overridable), using the same
"skip this bin, try the next; grow chunk count if none has room"
mechanism already proven safe for the existing char budget. This
bounds the compounding directly and protects every future batch of
newly-added, not-yet-measured conformance-tier files without depending
on anyone remembering to regenerate the timings table.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* chore(#4949): add changeset for windows conformance chunk cap fix

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4949): address standards-review findings — changeset format, cap-1 boundary test

Reformat the changeset body to the documented **bold** — explanation.
format (was missing the em-dash separator and had a period inside the
bold clause) and add the trailing (#4949) backlink. Add the missing
cap-1 boundary test case alongside the existing cap/cap+1 rows.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* chore(#4949): backfill changeset PR number (#4950)

* fix(#4949): don't apply the unmeasured-cap when no timings table loads at all

Caught live: PR #4950's own conformance test (windows-latest, 24,
shard 1/3) failed a pre-existing pinned test. makeMeasuredPredicate(null)
returns false for every file when the timings table is completely
missing/corrupt -- a different fact than "a loaded table exists but
doesn't cover this file". main() derived MAX_UNMEASURED_PER_CHUNK
unconditionally, so with no table at all every file counted as
"unmeasured" and the win32 cap of 2 split 7 files into 4 chunks instead
of the 3 the file-count-chunking contract (and a pinned test) require.

Gate the cap on loadedTimings() itself: no table -> Infinity (no cap,
matching the long-standing uniform-weight-1 no-table contract).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-09-23 15:26:20 -04:00
committed by GitHub
parent af822a8024
commit 6486647626
3 changed files with 298 additions and 2 deletions

View File

@@ -356,6 +356,20 @@ function defaultMaxFilesPerChunk(platform) {
return platform === 'win32' ? 22 : 60;
}
// Per-chunk UNMEASURED-file-count budget default, by platform. See the
// isMeasured/maxUnmeasuredPerChunk comment above packChunks's definition for
// the incident this guards (red next @ccfed6335 2026-09-20, @af822a80
// 2026-09-23 — both windows conformance shards, chunks killed at 600000ms+
// with zero failing tests) and why the bound is a COUNT of unmeasured files,
// not a weight: an unmeasured file's weight is a guess, and this cap exists
// precisely because several guesses compounding is what blew the budget, not
// because any individual guess was too large. win32-only — the failure has
// only ever been observed there; every other platform stays unbounded
// (Infinity), unchanged from today's behavior.
function defaultMaxUnmeasuredPerChunk(platform) {
return platform === 'win32' ? 2 : Infinity;
}
// ── #4020: run-scoped temp root ─────────────────────────────────────────────
//
// Fixture trees leak under os.tmpdir() on the SUCCESS path (the untouched half
@@ -640,10 +654,31 @@ function makeMeasuredPredicate(timings) {
// that file; when no chunk has room, the chunk count grows and packing restarts.
// A single file longer than the budget lands alone rather than looping forever.
//
// `isMeasured`/`maxUnmeasuredPerChunk` (2026-09-23, red `next` @ccfed6335 and
// @af822a80, both windows conformance shards, chunks killed at 600000ms+) add a
// THIRD, independent budget alongside weight and chars, for the same reason
// #4434 gave an unmeasured file its own windows multiplier instead of trusting
// its weight: an unmeasured file's weight is a GUESS, not a measurement, and
// several guesses landing in the same chunk let their individual uncertainty
// compound into a real failure no single file's weight predicted. Both
// incidents killed a chunk holding 5-6 files absent from tests/test-timings.json
// (new conformance-tier files added since the table was last regenerated) packed
// alongside the chunk's measured files — each guess looked affordable alone, the
// chunk's TOTAL weight still cleared the budget, and it still blew the 600s
// backstop. Isolation (partitionIsolatedFiles, above) cannot help here — it only
// pulls out a file PROVEN heavy, and an unmeasured file has no proof either way.
// Capping how many unmeasured files ANY one chunk may hold bounds the compounding
// directly, independent of what their guessed weight happens to be, using the
// exact same "skip this bin, try the next; grow the chunk count if none has
// room" mechanism already proven safe for the char budget below. `isMeasured` is
// optional (omitted callers/tests get today's behavior unchanged — no cap), and
// `maxUnmeasuredPerChunk` degrades to "no cap" for any non-finite or negative
// value, matching `maxWeight`/`maxChars`'s own degrade-safely contract.
//
// Ordering is fully deterministic — ties break on the separator-normalized file
// path, and each chunk's files are emitted in their original selection order —
// so the packing is byte-identical across Windows/macOS/Linux.
function packChunks(files, { weightOf, maxWeight, maxChars, fixedOverhead }) {
function packChunks(files, { weightOf, maxWeight, maxChars, fixedOverhead, isMeasured, maxUnmeasuredPerChunk }) {
if (files.length === 0) return [];
// packChunks is exported, so it cannot assume its caller normalized these.
// A non-finite or non-positive budget makes the chunk-count arithmetic
@@ -653,6 +688,10 @@ function packChunks(files, { weightOf, maxWeight, maxChars, fixedOverhead }) {
const weightBudget = Number.isFinite(maxWeight) && maxWeight > 0 ? maxWeight : files.length;
const charBudget = Number.isFinite(maxChars) && maxChars > 0 ? maxChars : Number.MAX_SAFE_INTEGER;
const overhead = Number.isFinite(fixedOverhead) && fixedOverhead >= 0 ? fixedOverhead : 0;
const unmeasuredBudget =
Number.isFinite(maxUnmeasuredPerChunk) && maxUnmeasuredPerChunk >= 0
? maxUnmeasuredPerChunk
: Infinity;
const safeWeight = (file) => {
const w = weightOf(file);
return Number.isFinite(w) && w >= 0 ? w : 0;
@@ -662,6 +701,7 @@ function packChunks(files, { weightOf, maxWeight, maxChars, fixedOverhead }) {
index,
weight: safeWeight(file),
chars: file.length + 1, // +1 for the inter-arg separator
measured: isMeasured ? !!isMeasured(file) : true,
}));
const totalWeight = entries.reduce((sum, e) => sum + e.weight, 0);
// Ties break on a SEPARATOR-NORMALIZED path so a subdir file orders the same
@@ -693,14 +733,18 @@ function packChunks(files, { weightOf, maxWeight, maxChars, fixedOverhead }) {
entries: [],
weight: 0,
chars: overhead,
unmeasuredCount: 0,
}));
let overflowed = false;
for (const entry of heaviestFirst) {
let target = null;
for (const bin of bins) {
// An empty bin always accepts, so an over-long single file lands alone
// instead of growing the chunk count forever.
// instead of growing the chunk count forever. Same rule for the
// unmeasured-count budget below — it exists to spread uncertainty
// across chunks, not to make a lone unmeasured file unplaceable.
if (bin.entries.length > 0 && bin.chars + entry.chars > charBudget) continue;
if (bin.entries.length > 0 && !entry.measured && bin.unmeasuredCount >= unmeasuredBudget) continue;
if (target === null || bin.weight < target.weight) target = bin;
}
if (target === null) {
@@ -710,6 +754,7 @@ function packChunks(files, { weightOf, maxWeight, maxChars, fixedOverhead }) {
target.entries.push(entry);
target.weight += entry.weight;
target.chars += entry.chars;
if (!entry.measured) target.unmeasuredCount += 1;
}
if (!overflowed) {
return bins
@@ -1490,6 +1535,30 @@ function main() {
process.env.RUN_TESTS_MAX_FILES_PER_CHUNK,
DEFAULT_MAX_FILES_PER_CHUNK,
);
// See packChunks' isMeasured/maxUnmeasuredPerChunk comment (above its
// definition) and defaultMaxUnmeasuredPerChunk (above) for the incident and
// rationale. RUN_TESTS_MAX_UNMEASURED_PER_CHUNK overrides for operators/tests,
// same pattern as every other *_PER_CHUNK knob in this file.
//
// Gated on loadedTimings() itself (not just deferring to isMeasured's
// per-file answer): makeMeasuredPredicate(null) — a completely missing or
// corrupt table — returns `false` for EVERY file, which is a different fact
// than "a loaded table exists but doesn't cover this file." The cap exists
// to bound uncertainty among files a mostly-reliable table failed to cover,
// not to re-litigate the no-table case, which has its own long-standing
// contract (makeFileWeigher's `if (!timings) return () => 1`): uniform
// weight 1, pure count-based packing, unaffected by this cap. Caught live
// (red conformance test (windows-latest, 24, shard 1/3), PR #4950): with no
// table loaded, every one of 7 files in
// "chunks by file count even when argv length is below the ceiling" was
// "unmeasured", and the win32 cap of 2 split them into 4 chunks instead of
// the 3 that test — and the uniform-weight-1 contract — require.
const MAX_UNMEASURED_PER_CHUNK = loadedTimings()
? positiveNumberEnv(
process.env.RUN_TESTS_MAX_UNMEASURED_PER_CHUNK,
defaultMaxUnmeasuredPerChunk(process.platform),
)
: Infinity;
// #2088 established that file COUNT is a poor proxy for a chunk's wall-clock:
// install-heavy files (real installs) cost ~10x a unit file, and when several
// land in the SAME chunk it blows the 600s backstop while unit-only chunks
@@ -1632,6 +1701,8 @@ function main() {
maxWeight: MAX_FILES_PER_CHUNK,
maxChars: MAX_CMDLINE_CHARS,
fixedOverhead: FIXED_OVERHEAD,
isMeasured: fileMeasuredOf(),
maxUnmeasuredPerChunk: MAX_UNMEASURED_PER_CHUNK,
}),
];
@@ -1881,6 +1952,7 @@ module.exports = {
selectShard,
positiveNumberEnv,
defaultMaxFilesPerChunk,
defaultMaxUnmeasuredPerChunk,
loadTestTimings,
makeFileWeigher,
WINDOWS_UNMEASURED_COST_MULTIPLIER,