* 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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user