Skip to content

Commit 96f514d

Browse files
authored
fix(comment): bound errored-project rendering to prevent comment-generation hang (#21)
* fix(comment): bound errored-project rendering to prevent comment-generation hang formatErroredProject built its output with `s += ...` while splitting the diagnostic message on ": " and applying a per-piece growing indent via strings.Repeat. A large critical diagnostic (e.g. a multi-MB Terraform HCL parse error, which also contains many ": " separators) turned this into an O(n^2) string build, spinning a single goroutine on-CPU indefinitely. This runs inside GenerateComment, before renderWithTruncation, so the 64KB comment cap never kicked in. Fixes: - formatErroredProject uses strings.Builder, caps each diagnostic message to 4KB before splitting, caps indent depth, and caps the number of critical diagnostics rendered per project (10) with a summarised remainder. - processProjectCostDetails caps the combined errored-projects section (32KB), since the whole section is built before truncation. Adds a regression test that hangs on the old implementation and completes instantly with the fix. * fix(comment): use fmt.Fprintf to satisfy staticcheck QF1012
1 parent bf087ec commit 96f514d

2 files changed

Lines changed: 132 additions & 10 deletions

File tree

pkg/vcs/comment/cost_details.go

Lines changed: 68 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,26 @@ func (data *Data) processProjectCostDetails(inputs *Inputs) {
8787
}
8888

8989
if len(erroredProjects) > 0 {
90-
for _, project := range erroredProjects {
91-
parts = append(parts, projectTitle(project))
92-
parts = append(parts, formatErroredProject(project))
93-
parts = append(parts, "\n"+separator)
90+
var erroredBytes int
91+
var omitted int
92+
for i, project := range erroredProjects {
93+
// Defence in depth: even with each message individually capped, a run
94+
// with many errored projects could produce a huge section that the
95+
// truncation pass then has to churn through. Stop once the section
96+
// exceeds its budget and note how many projects were left out.
97+
if erroredBytes > maxErroredProjectsSectionBytes {
98+
omitted = len(erroredProjects) - i
99+
break
100+
}
101+
title := projectTitle(project)
102+
body := formatErroredProject(project)
103+
tail := "\n" + separator
104+
erroredBytes += len(title) + len(body) + len(tail)
105+
parts = append(parts, title, body, tail)
106+
}
107+
if omitted > 0 {
108+
parts = append(parts, fmt.Sprintf("… and %d more project(s) with errors (output truncated).\n", omitted))
109+
parts = append(parts, separator)
94110
}
95111
}
96112

@@ -413,23 +429,65 @@ func formatResourceCounts(counts map[string]int) string {
413429
return msg
414430
}
415431

432+
const (
433+
// maxErroredDiagnosticBytes caps a single critical diagnostic message before
434+
// it is rendered. Diagnostic errors (e.g. a Terraform HCL parse failure) can
435+
// be many megabytes; without a cap, formatErroredProject's per-piece
436+
// rendering degrades to O(n^2) and comment generation stalls indefinitely.
437+
maxErroredDiagnosticBytes = 4096
438+
439+
// maxErroredIndentDepth caps the progressive indent applied to each ": "
440+
// separated piece, so a message containing a large number of separators
441+
// can't amplify into an enormous string.
442+
maxErroredIndentDepth = 8
443+
444+
// maxErroredProjectsSectionBytes caps the combined size of the errored
445+
// projects section. The whole section is built before renderWithTruncation
446+
// runs, so this bounds the work regardless of how many projects errored.
447+
maxErroredProjectsSectionBytes = 32 * 1024
448+
449+
// maxErroredDiagnosticsPerProject caps how many critical diagnostics are
450+
// rendered for a single project; the rest are summarised as a count.
451+
maxErroredDiagnosticsPerProject = 10
452+
)
453+
416454
func formatErroredProject(pr ProjectResult) string {
417-
s := "Errors:\n"
455+
var b strings.Builder
456+
b.WriteString("Errors:\n")
457+
var shown int
458+
var omitted int
418459
for _, diag := range pr.Diagnostics {
419460
if !diag.Critical {
420461
continue
421462
}
422-
pieces := strings.Split(diag.FormatMessage(), ": ")
463+
if shown >= maxErroredDiagnosticsPerProject {
464+
omitted++
465+
continue
466+
}
467+
shown++
468+
msg := diag.FormatMessage()
469+
if len(msg) > maxErroredDiagnosticBytes {
470+
msg = prefixWithin(msg, maxErroredDiagnosticBytes, SizeUnitBytes) + "…"
471+
}
472+
pieces := strings.Split(msg, ": ")
423473
for x, piece := range pieces {
424-
s += strings.Repeat(" ", x+1) + piece
474+
indent := x + 1
475+
if indent > maxErroredIndentDepth {
476+
indent = maxErroredIndentDepth
477+
}
478+
b.WriteString(strings.Repeat(" ", indent))
479+
b.WriteString(piece)
425480
if x == len(pieces)-1 {
426-
s += "\n"
481+
b.WriteString("\n")
427482
} else {
428-
s += ":\n"
483+
b.WriteString(":\n")
429484
}
430485
}
431486
}
432-
return s
487+
if omitted > 0 {
488+
fmt.Fprintf(&b, " … and %d more error(s).\n", omitted)
489+
}
490+
return b.String()
433491
}
434492

435493
func sortBreakdownResources(resources []BreakdownResource) {
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package comment
2+
3+
import (
4+
"strings"
5+
"testing"
6+
"time"
7+
8+
"github.com/infracost/go-proto/pkg/diagnostic"
9+
parserpb "github.com/infracost/proto/gen/go/infracost/parser"
10+
)
11+
12+
// TestFormatErroredProject_Pathological guards against the O(n^2) blow-up that
13+
// caused comment generation to hang indefinitely: a single critical diagnostic
14+
// with a very large error string containing many ": " separators. Before the
15+
// fix this took effectively forever; it must now complete near-instantly and
16+
// stay bounded in size.
17+
func TestFormatErroredProject_Pathological(t *testing.T) {
18+
huge := strings.Repeat("a: ", 2_000_000) // ~6MB, ~2M ": " separators
19+
20+
pr := ProjectResult{
21+
Name: "big-error",
22+
Diagnostics: []*diagnostic.Diagnostic{
23+
{Critical: true, Type: parserpb.DiagnosticType_DIAGNOSTIC_TYPE_HCL_PARSE_ERROR, Error: huge},
24+
},
25+
}
26+
27+
done := make(chan string, 1)
28+
go func() { done <- formatErroredProject(pr) }()
29+
30+
select {
31+
case out := <-done:
32+
// The single message is capped before rendering, so output stays a small
33+
// bounded multiple of the cap (per-piece indent + ":\n" markers add a
34+
// constant factor) rather than exploding with the ~6MB input.
35+
if maxBytes := maxErroredDiagnosticBytes * 6; len(out) > maxBytes {
36+
t.Fatalf("output not bounded: got %d bytes, want <= %d", len(out), maxBytes)
37+
}
38+
case <-time.After(5 * time.Second):
39+
t.Fatal("formatErroredProject did not complete in time (quadratic blow-up regression)")
40+
}
41+
}
42+
43+
// TestFormatErroredProject_LimitsErrorCount verifies that only the first
44+
// maxErroredDiagnosticsPerProject critical diagnostics are rendered and the
45+
// remainder are summarised as a count.
46+
func TestFormatErroredProject_LimitsErrorCount(t *testing.T) {
47+
var diags []*diagnostic.Diagnostic
48+
for i := 0; i < maxErroredDiagnosticsPerProject+5; i++ {
49+
diags = append(diags, &diagnostic.Diagnostic{
50+
Critical: true,
51+
Type: parserpb.DiagnosticType_DIAGNOSTIC_TYPE_HCL_PARSE_ERROR,
52+
Error: "boom",
53+
})
54+
}
55+
pr := ProjectResult{Name: "many-errors", Diagnostics: diags}
56+
57+
out := formatErroredProject(pr)
58+
if !strings.Contains(out, "and 5 more error(s)") {
59+
t.Fatalf("expected omitted-count summary, got:\n%s", out)
60+
}
61+
if got := strings.Count(out, "HCL parse error"); got != maxErroredDiagnosticsPerProject {
62+
t.Fatalf("expected %d rendered diagnostics, got %d", maxErroredDiagnosticsPerProject, got)
63+
}
64+
}

0 commit comments

Comments
 (0)