Skip to content

Commit db7a98b

Browse files
committed
feat: show failures that prevent CI resumes
Print the matching diagnostic and console log URL when a changed file prevents resuming CI. Bound large excerpts and preserve early download cancellation. Assisted-by: Codex Signed-off-by: Filip Skokan <panva.ip@gmail.com>
1 parent e2fc177 commit db7a98b

6 files changed

Lines changed: 367 additions & 34 deletions

File tree

‎docs/ncu-ci.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,8 @@ Before resuming, the command streams failed-job console output and compares
247247
failure diagnostics with the PR's changed files. When recovering through resume
248248
ancestry, it checks the latest run and every ancestor visited. It refuses to resume
249249
if a failed test or a file referenced in a failure diagnostic is changed by the PR.
250+
The refusal includes the first matching filename, failure excerpt, and console
251+
log URL. Large excerpts are truncated with an explicit marker.
250252
Logs are scanned one at a time with bounded memory. HTTP compression is decoded as the
251253
response arrives. A match cancels the download and skips remaining logs. Unknown
252254
or unavailable failure details do not prevent resuming; the check uses the

‎lib/ci/failure_file_scanner.js‎

Lines changed: 60 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,25 @@ const infrastructure = createMatcher(FAILURE_PATTERNS.infrastructure);
1414
const gitStart = createMatcher(FAILURE_MARKERS.git.map(({ start }) => start));
1515
const gitEnd = createMatcher(FAILURE_MARKERS.git.map(({ end }) => end));
1616

17+
// Keep both ends of large diagnostics without retaining whole lines or TAP blocks.
18+
class FailureExcerpt {
19+
head = '';
20+
tail = '';
21+
length = 0;
22+
23+
append(text) {
24+
const remaining = Math.max(0, 4096 - this.head.length);
25+
this.head += text.slice(0, remaining);
26+
this.tail = (this.tail + text.slice(remaining)).slice(-4096);
27+
this.length += text.length;
28+
}
29+
30+
toString() {
31+
const omitted = this.length > 8192 ? '\n[... failure output truncated ...]\n' : '';
32+
return this.head + omitted + this.tail;
33+
}
34+
}
35+
1736
function fileAliases(filename) {
1837
const paths = new Set([filename]);
1938
if (filename.startsWith('test/')) {
@@ -35,15 +54,16 @@ async function * logWindows(source, overlap) {
3554

3655
function * consume(raw) {
3756
raw = raw.replace(/\r/g, '');
38-
let text = raw.replace(/\\+/g, '/');
39-
if (afterBackslash && raw.startsWith('\\')) text = text.slice(1);
40-
if (raw) afterBackslash = raw.endsWith('\\');
41-
for (let offset = 0; offset < text.length;) {
42-
const newline = text.indexOf('\n', offset);
43-
const end = Math.min(offset + 8192, newline < 0 ? text.length : newline + 1);
44-
const window = tail + text.slice(offset, end);
57+
for (let offset = 0; offset < raw.length;) {
58+
const newline = raw.indexOf('\n', offset);
59+
const end = Math.min(offset + 8192, newline < 0 ? raw.length : newline + 1);
60+
const content = raw.slice(offset, end);
61+
let normalized = content.replace(/\\+/g, '/');
62+
if (afterBackslash && content.startsWith('\\')) normalized = normalized.slice(1);
63+
afterBackslash = content.endsWith('\\');
64+
const window = tail + normalized;
4565
const lineEnd = end === newline + 1;
46-
yield { text: window, lineStart, lineEnd };
66+
yield { text: window, content, lineStart, lineEnd };
4767
if (lineEnd) {
4868
tail = '';
4969
lineStart = true;
@@ -57,14 +77,15 @@ async function * logWindows(source, overlap) {
5777

5878
for await (const chunk of source) yield * consume(decoder.write(chunk));
5979
yield * consume(decoder.end());
60-
if (tail) yield { text: `${tail}\n`, lineStart, lineEnd: true };
80+
if (tail) yield { text: `${tail}\n`, content: '\n', lineStart, lineEnd: true };
6181
}
6282

63-
// Each line is reduced to a filename and failure markers. Neither a giant line
64-
// nor a giant TAP block needs to survive in memory.
83+
// Retain failure markers and a bounded excerpt for each line.
6584
async function * failureLines(windows, matchFile) {
6685
let line = {};
67-
for await (const { text, lineStart, lineEnd } of windows) {
86+
let excerpt = new FailureExcerpt();
87+
for await (const { text, content, lineStart, lineEnd } of windows) {
88+
excerpt.append(content);
6889
line.file ??= matchFile(text, lineStart);
6990
line.diagnostic ||= diagnostic(text);
7091
line.infrastructure ||= infrastructure(text);
@@ -75,53 +96,71 @@ async function * failureLines(windows, matchFile) {
7596
line.gitStart ||= gitStart(text);
7697
line.gitEnd ||= gitEnd(text);
7798
if (lineEnd) {
99+
line.text = excerpt.toString();
78100
yield line;
79101
line = {};
102+
excerpt = new FailureExcerpt();
80103
}
81104
}
82105
}
83106

84107
async function findFailure(lines) {
85108
let history = [];
86109
let followingLines = 0;
110+
let diagnosticExcerpt;
87111
let tap = null;
88112
let git = null;
89113

90114
for await (const line of lines) {
91115
const precedingLines = history;
92-
history = [...history, line.file].slice(-5);
116+
history = [...history, line].slice(-5);
93117
if (line.tapStart) {
94-
tap = {};
118+
tap = { excerpt: new FailureExcerpt() };
95119
followingLines = 0;
96120
}
97121
if (tap) {
122+
tap.excerpt.append(line.text);
98123
tap.file ??= line.file;
99124
tap.todo ||= line.todo;
100125
// A later TODO can mark this as an expected failure; wait for the ending.
101126
if (line.tapEnd) {
102-
if (!tap.todo && tap.file) return tap.file;
127+
if (!tap.todo && tap.file) {
128+
return { filename: tap.file, reason: tap.excerpt.toString().trimEnd() };
129+
}
103130
tap = null;
104131
}
105132
continue;
106133
}
107134

108-
if (line.gitStart) git = {};
135+
if (line.gitStart) git = { excerpt: new FailureExcerpt() };
109136
if (git) {
137+
git.excerpt.append(line.text);
110138
git.file ??= line.file;
111139
if (line.gitEnd) {
112-
if (git.file) return git.file;
140+
if (git.file) return { filename: git.file, reason: git.excerpt.toString().trimEnd() };
113141
git = null;
114142
}
115143
}
116144
if (line.infrastructure || line.cpp) {
117145
const before = line.cpp ? 5 : 1;
118-
const file = line.file || precedingLines.slice(-before).find(Boolean);
119-
if (file) return file;
146+
const context = [...precedingLines.slice(-before), line];
147+
const filename = line.file || context.find(previous => previous.file)?.file;
148+
if (filename) {
149+
const excerpt = new FailureExcerpt();
150+
for (const previous of context) excerpt.append(previous.text);
151+
return { filename, reason: excerpt.toString().trimEnd() };
152+
}
153+
}
154+
if (line.diagnostic) {
155+
followingLines = 6;
156+
diagnosticExcerpt = new FailureExcerpt();
120157
}
121-
if (line.diagnostic) followingLines = 6;
122158
if (followingLines > 0) {
123159
followingLines--;
124-
if (line.file) return line.file;
160+
diagnosticExcerpt.append(line.text);
161+
if (line.file) {
162+
return { filename: line.file, reason: diagnosticExcerpt.toString().trimEnd() };
163+
}
125164
}
126165
}
127166
}

‎lib/ci/resume_ci.js‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export class ResumePRJob {
3333
filenames.add(filename);
3434
}
3535
}
36-
const overlaps = new Set();
36+
let overlap;
3737
const buildRequest = Object.create(request);
3838
buildRequest.json = async(...args) => {
3939
const data = await request.json(...args);
@@ -46,10 +46,10 @@ export class ResumePRJob {
4646
let pending = Promise.resolve();
4747
buildRequest.text = (url) => {
4848
const scan = pending.then(async() => {
49-
if (!filenames.size || overlaps.size) return '';
49+
if (!filenames.size || overlap) return '';
5050
const scanner = new FailureFileScanner(filenames);
5151
const match = await scanner.scan(request.stream(url));
52-
if (match) overlaps.add(match);
52+
if (match) overlap = { ...match, url };
5353
return '';
5454
});
5555
pending = scan.catch(debuglog);
@@ -62,10 +62,10 @@ export class ResumePRJob {
6262
debuglog(err);
6363
}
6464
await pending;
65-
if (overlaps.size) {
66-
for (const filename of [...overlaps].sort()) {
67-
cli.error(filename);
68-
}
65+
if (overlap) {
66+
cli.error(overlap.filename);
67+
cli.info(overlap.url);
68+
cli.log(overlap.reason);
6969
return false;
7070
}
7171
return true;

0 commit comments

Comments
 (0)