detached 3d4e205

fix(executor): stop problem matchers when the step is cancelled

The output manager gets the cancellation channel of the step. A match in
progress is abandoned, no further matcher or attempt runs, and a timeout the
cancellation caused is not counted, so the final drain of a step with several
catastrophic matchers no longer holds it past timeout-minutes.
commit
3d4e205848bfa2ceb541054c711da14ff568fc57
author
brice · · 2026-10-14 02:42 UTC
parent
9911deb
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +99 -6
M internal/executor/exec_step.go +1 -1
@@ -192,7 +192,7 @@ func (j *job) processEnv(stepEnv *ordered) []string {
192192 // step.
193193 func (j *job) startProcess(s *stepRun, plan ShellPlan, dir string, argv []string, stepCancel <-chan struct{}) stepResult {
194194 num := s.num
195- om := newOutputManager(s)
195+ om := newOutputManager(s, stepCancel)
196196 defer om.dispose()
197197 res, err := runProcess(procSpec{
198198 Argv: argv, Dir: dir, Env: j.processEnv(s.env), Credential: j.cfg.Credential,
@@ -192,7 +192,7 @@ func (j *job) processEnv(stepEnv *ordered) []string {
192 // step.192 // step.
193 func (j *job) startProcess(s *stepRun, plan ShellPlan, dir string, argv []string, stepCancel <-chan struct{}) stepResult {193 func (j *job) startProcess(s *stepRun, plan ShellPlan, dir string, argv []string, stepCancel <-chan struct{}) stepResult {
194 num := s.num194 num := s.num
195- om := newOutputManager(s)195+ om := newOutputManager(s, stepCancel)
196 defer om.dispose()196 defer om.dispose()
197 res, err := runProcess(procSpec{197 res, err := runProcess(procSpec{
198 Argv: argv, Dir: dir, Env: j.processEnv(s.env), Credential: j.cfg.Credential,198 Argv: argv, Dir: dir, Env: j.processEnv(s.env), Credential: j.cfg.Credential,
M internal/executor/outputmgr.go +62 -5
@@ -114,7 +114,10 @@ func matcherTimeout(getenv func(string) string) time.Duration {
114114 }
115115
116116 type outputManager struct {
117- s *stepRun
117+ s *stepRun
118+ // cancel is closed when the step is cancelled or times out: the matchers
119+ // then stop (see stopped). Nil means never.
120+ cancel <-chan struct{}
118121 root *matcherRoot
119122 timeout time.Duration
120123
@@ -125,8 +128,14 @@ type outputManager struct {
125128 }
126129
127130 // newOutputManager subscribes to the matchers of the job.
128-func newOutputManager(s *stepRun) *outputManager {
129- o := &outputManager{s: s, root: s.j.matchers, timeout: matcherTimeout(s.j.getenv), dirMap: map[string]string{}, hasDir: map[string]bool{}}
131+//
132+// cancel is the cancellation channel of the step (the one the process gets):
133+// once it is closed no matcher runs any more. The runner has no such rule, but
134+// its worker waits for the output of a step with the step's cancellation
135+// token, and a pile of lines against catastrophic patterns (1 s each, 3
136+// attempts, per matcher) would hold a step past its timeout for minutes.
137+func newOutputManager(s *stepRun, cancel <-chan struct{}) *outputManager {
138+ o := &outputManager{s: s, cancel: cancel, root: s.j.matchers, timeout: matcherTimeout(s.j.getenv), dirMap: map[string]string{}, hasDir: map[string]bool{}}
130139 o.mu.Lock()
131140 o.root.subscribe(o)
132141 for _, c := range o.root.snapshot() {
@@ -138,6 +147,48 @@ func newOutputManager(s *stepRun) *outputManager {
138147 return o
139148 }
140149
150+// errStopped is the result of a match that was abandoned because the step was
151+// cancelled.
152+var errStopped = msgError("matching stopped: the step was cancelled")
153+
154+// runMatch is m.match, abandoned when the step is cancelled while it runs: the
155+// engine cannot be interrupted, so it goes on in its goroutine until its own
156+// timeout, but the step does not wait for it and the matcher is not used again
157+// (stopped is true from then on).
158+func (o *outputManager) runMatch(m *issueMatcher, line string) (*issueMatch, error) {
159+ if o.cancel == nil {
160+ return m.match(line)
161+ }
162+ type result struct {
163+ m *issueMatch
164+ err error
165+ }
166+ ch := make(chan result, 1)
167+ go func() {
168+ r, err := m.match(line)
169+ ch <- result{r, err}
170+ }()
171+ select {
172+ case r := <-ch:
173+ return r.m, r.err
174+ case <-o.cancel:
175+ return nil, errStopped
176+ }
177+}
178+
179+// stopped reports whether the step was cancelled.
180+func (o *outputManager) stopped() bool {
181+ if o.cancel == nil {
182+ return false
183+ }
184+ select {
185+ case <-o.cancel:
186+ return true
187+ default:
188+ return false
189+ }
190+}
191+
141192 func (o *outputManager) dispose() { o.root.unsubscribe(o) }
142193
143194 // matcherChanged is OnMatcherChanged: the matcher goes first, the others of
@@ -188,7 +239,7 @@ func (o *outputManager) onData(line string) {
188239 o.mu.Lock()
189240 matchers := append([]*issueMatcher(nil), o.matchers...)
190241 o.mu.Unlock()
191- if len(matchers) > 0 {
242+ if len(matchers) > 0 && !o.stopped() {
192243 stripped := line
193244 if strings.Contains(line, colorCodePrefix) {
194245 stripped, _ = colorCodeRegex.Replace(line, "", -1, -1)
@@ -196,11 +247,17 @@ func (o *outputManager) onData(line string) {
196247 for _, m := range matchers {
197248 var match *issueMatch
198249 for attempt := 1; attempt <= maxMatcherAttempts; attempt++ {
199- mt, err := m.match(stripped)
250+ if o.stopped() {
251+ break // the lines that are left are only logged
252+ }
253+ mt, err := o.runMatch(m, stripped)
200254 if err == nil {
201255 match = mt
202256 break
203257 }
258+ if o.stopped() {
259+ break // a timeout that the cancellation caused counts for nothing
260+ }
204261 if attempt < maxMatcherAttempts {
205262 s.Debug(fmt.Sprintf("Timeout processing issue matcher '%s' against line '%s'. Exception: System.Text.RegularExpressions.RegexMatchTimeoutException: %s", m.owner, stripped, err.Error()))
206263 } else {
@@ -114,7 +114,10 @@ func matcherTimeout(getenv func(string) string) time.Duration {
114 }114 }
115 115
116 type outputManager struct {116 type outputManager struct {
117- s *stepRun117+ s *stepRun
118+ // cancel is closed when the step is cancelled or times out: the matchers
119+ // then stop (see stopped). Nil means never.
120+ cancel <-chan struct{}
118 root *matcherRoot121 root *matcherRoot
119 timeout time.Duration122 timeout time.Duration
120 123
@@ -125,8 +128,14 @@ type outputManager struct {
125 }128 }
126 129
127 // newOutputManager subscribes to the matchers of the job.130 // newOutputManager subscribes to the matchers of the job.
128-func newOutputManager(s *stepRun) *outputManager {131+//
129- o := &outputManager{s: s, root: s.j.matchers, timeout: matcherTimeout(s.j.getenv), dirMap: map[string]string{}, hasDir: map[string]bool{}}132+// cancel is the cancellation channel of the step (the one the process gets):
133+// once it is closed no matcher runs any more. The runner has no such rule, but
134+// its worker waits for the output of a step with the step's cancellation
135+// token, and a pile of lines against catastrophic patterns (1 s each, 3
136+// attempts, per matcher) would hold a step past its timeout for minutes.
137+func newOutputManager(s *stepRun, cancel <-chan struct{}) *outputManager {
138+ o := &outputManager{s: s, cancel: cancel, root: s.j.matchers, timeout: matcherTimeout(s.j.getenv), dirMap: map[string]string{}, hasDir: map[string]bool{}}
130 o.mu.Lock()139 o.mu.Lock()
131 o.root.subscribe(o)140 o.root.subscribe(o)
132 for _, c := range o.root.snapshot() {141 for _, c := range o.root.snapshot() {
@@ -138,6 +147,48 @@ func newOutputManager(s *stepRun) *outputManager {
138 return o147 return o
139 }148 }
140 149
150+// errStopped is the result of a match that was abandoned because the step was
151+// cancelled.
152+var errStopped = msgError("matching stopped: the step was cancelled")
153+
154+// runMatch is m.match, abandoned when the step is cancelled while it runs: the
155+// engine cannot be interrupted, so it goes on in its goroutine until its own
156+// timeout, but the step does not wait for it and the matcher is not used again
157+// (stopped is true from then on).
158+func (o *outputManager) runMatch(m *issueMatcher, line string) (*issueMatch, error) {
159+ if o.cancel == nil {
160+ return m.match(line)
161+ }
162+ type result struct {
163+ m *issueMatch
164+ err error
165+ }
166+ ch := make(chan result, 1)
167+ go func() {
168+ r, err := m.match(line)
169+ ch <- result{r, err}
170+ }()
171+ select {
172+ case r := <-ch:
173+ return r.m, r.err
174+ case <-o.cancel:
175+ return nil, errStopped
176+ }
177+}
178+
179+// stopped reports whether the step was cancelled.
180+func (o *outputManager) stopped() bool {
181+ if o.cancel == nil {
182+ return false
183+ }
184+ select {
185+ case <-o.cancel:
186+ return true
187+ default:
188+ return false
189+ }
190+}
191+
141 func (o *outputManager) dispose() { o.root.unsubscribe(o) }192 func (o *outputManager) dispose() { o.root.unsubscribe(o) }
142 193
143 // matcherChanged is OnMatcherChanged: the matcher goes first, the others of194 // matcherChanged is OnMatcherChanged: the matcher goes first, the others of
@@ -188,7 +239,7 @@ func (o *outputManager) onData(line string) {
188 o.mu.Lock()239 o.mu.Lock()
189 matchers := append([]*issueMatcher(nil), o.matchers...)240 matchers := append([]*issueMatcher(nil), o.matchers...)
190 o.mu.Unlock()241 o.mu.Unlock()
191- if len(matchers) > 0 {242+ if len(matchers) > 0 && !o.stopped() {
192 stripped := line243 stripped := line
193 if strings.Contains(line, colorCodePrefix) {244 if strings.Contains(line, colorCodePrefix) {
194 stripped, _ = colorCodeRegex.Replace(line, "", -1, -1)245 stripped, _ = colorCodeRegex.Replace(line, "", -1, -1)
@@ -196,11 +247,17 @@ func (o *outputManager) onData(line string) {
196 for _, m := range matchers {247 for _, m := range matchers {
197 var match *issueMatch248 var match *issueMatch
198 for attempt := 1; attempt <= maxMatcherAttempts; attempt++ {249 for attempt := 1; attempt <= maxMatcherAttempts; attempt++ {
199- mt, err := m.match(stripped)250+ if o.stopped() {
251+ break // the lines that are left are only logged
252+ }
253+ mt, err := o.runMatch(m, stripped)
200 if err == nil {254 if err == nil {
201 match = mt255 match = mt
202 break256 break
203 }257 }
258+ if o.stopped() {
259+ break // a timeout that the cancellation caused counts for nothing
260+ }
204 if attempt < maxMatcherAttempts {261 if attempt < maxMatcherAttempts {
205 s.Debug(fmt.Sprintf("Timeout processing issue matcher '%s' against line '%s'. Exception: System.Text.RegularExpressions.RegexMatchTimeoutException: %s", m.owner, stripped, err.Error()))262 s.Debug(fmt.Sprintf("Timeout processing issue matcher '%s' against line '%s'. Exception: System.Text.RegularExpressions.RegexMatchTimeoutException: %s", m.owner, stripped, err.Error()))
206 } else {263 } else {
M internal/executor/results_test.go +36 -0
@@ -338,3 +338,39 @@ func TestSaveStateIsKeptPerStep(t *testing.T) {
338338 t.Errorf("%v", got)
339339 }
340340 }
341+
342+// Several catastrophic matchers and a step timeout: the step ends at its
343+// timeout, not after every matcher has used its 3 attempts on every line
344+// (GO-004). Without the cancellation of the matchers the 8 matchers x 3
345+// attempts x 1 s of the first line alone hold the step for 24 s.
346+func TestCatastrophicMatchersDoNotHoldAStepPastItsTimeout(t *testing.T) {
347+ needTools(t, "bash")
348+ var matchers []string
349+ var adds []string
350+ for i := range 8 {
351+ matchers = append(matchers, fmt.Sprintf(`{"owner":"bad%d","pattern":[{"regexp":"^((a+)+)$","message":1}]}`, i))
352+ }
353+ adds = append(adds, `echo '{"problemMatcher":[`+strings.Join(matchers, ",")+`]}' > bad.json`)
354+ start := time.Now()
355+ rr, _ := runResults(t, wfHead+` steps:
356+ - name: slow lines
357+ timeout-minutes: 1
358+ run: |
359+ `+adds[0]+`
360+ echo "::add-matcher::bad.json"
361+ for i in 1 2 3 4 5 6; do echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!"; done
362+ sleep 30
363+`, func(c *Config) { c.StepTimeoutSkew = -(60*time.Second - 1500*time.Millisecond) })
364+ took := time.Since(start)
365+ if took > 8*time.Second {
366+ t.Errorf("the step overran its timeout: %v", took)
367+ }
368+ log := logOf(rr.step(t, "slow lines"))
369+ if !strings.Contains(log, "The action 'slow lines' has timed out after 1 minutes.") {
370+ t.Errorf("no timeout message:\n%s", log)
371+ }
372+ // the lines that were not matched are still logged
373+ if strings.Count(log, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!") < 6 {
374+ t.Errorf("lines lost after the cancellation:\n%s", log)
375+ }
376+}
@@ -338,3 +338,39 @@ func TestSaveStateIsKeptPerStep(t *testing.T) {
338 t.Errorf("%v", got)338 t.Errorf("%v", got)
339 }339 }
340 }340 }
341+
342+// Several catastrophic matchers and a step timeout: the step ends at its
343+// timeout, not after every matcher has used its 3 attempts on every line
344+// (GO-004). Without the cancellation of the matchers the 8 matchers x 3
345+// attempts x 1 s of the first line alone hold the step for 24 s.
346+func TestCatastrophicMatchersDoNotHoldAStepPastItsTimeout(t *testing.T) {
347+ needTools(t, "bash")
348+ var matchers []string
349+ var adds []string
350+ for i := range 8 {
351+ matchers = append(matchers, fmt.Sprintf(`{"owner":"bad%d","pattern":[{"regexp":"^((a+)+)$","message":1}]}`, i))
352+ }
353+ adds = append(adds, `echo '{"problemMatcher":[`+strings.Join(matchers, ",")+`]}' > bad.json`)
354+ start := time.Now()
355+ rr, _ := runResults(t, wfHead+` steps:
356+ - name: slow lines
357+ timeout-minutes: 1
358+ run: |
359+ `+adds[0]+`
360+ echo "::add-matcher::bad.json"
361+ for i in 1 2 3 4 5 6; do echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!"; done
362+ sleep 30
363+`, func(c *Config) { c.StepTimeoutSkew = -(60*time.Second - 1500*time.Millisecond) })
364+ took := time.Since(start)
365+ if took > 8*time.Second {
366+ t.Errorf("the step overran its timeout: %v", took)
367+ }
368+ log := logOf(rr.step(t, "slow lines"))
369+ if !strings.Contains(log, "The action 'slow lines' has timed out after 1 minutes.") {
370+ t.Errorf("no timeout message:\n%s", log)
371+ }
372+ // the lines that were not matched are still logged
373+ if strings.Count(log, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!") < 6 {
374+ t.Errorf("lines lost after the cancellation:\n%s", log)
375+ }
376+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 11

↑↓ moveenter openesc close