Skip to content

fix(server/triggers): honor MaxConcurrentRuns in PubSubController - #1720

Merged
wolo-lab merged 4 commits into
google:mainfrom
PratikDhanaveFork:fix/pubsub-maxconcurrentruns-semaphore
Oct 7, 2026
Merged

wolo-lab merged 4 commits into
google:mainfrom
PratikDhanaveFork:fix/pubsub-maxconcurrentruns-semaphore

Conversation

@PratikDhanave

Copy link
Copy Markdown
Contributor

Summary

NewPubSubControllerWithConfig never initialized the semaphore field, so the concurrency guard in PubSubTriggerHandler is dead code and TriggerConfig.MaxConcurrentRuns is silently ignored — PubSub triggers run agents with unbounded concurrency.

The handler gates on the semaphore (server/adkrest/controllers/triggers/pubsub.go):

if c.semaphore != nil {
    c.semaphore <- struct{}{}
    defer func() { <-c.semaphore }()
}

but the constructor returned &PubSubController{runner: retriable} with semaphore left nil. The structurally-identical sibling NewEventarcControllerWithConfig initializes it correctly:

semaphore: make(chan struct{}, cfg.TriggerConfig.MaxConcurrentRuns),

Change

  • Initialize PubSubController.semaphore from cfg.TriggerConfig.MaxConcurrentRuns, mirroring the Eventarc controller (one line).
  • Add a white-box regression test asserting both controllers size their semaphore from MaxConcurrentRuns.

Verification

  • The new test fails before the fix (pubsub semaphore cap = 0, want 4) and passes after.
  • go build ./..., go vet ./server/adkrest/controllers/triggers/, and go test ./server/adkrest/controllers/triggers/ all pass.

🤖 Generated with Claude Code

NewPubSubControllerWithConfig never initialized the semaphore field, so the
`if c.semaphore != nil` guard in PubSubTriggerHandler never fired and
TriggerConfig.MaxConcurrentRuns was silently ignored — PubSub triggers ran
agents with unbounded concurrency. The sibling NewEventarcControllerWithConfig
already initializes it correctly (eventarc.go); this mirrors that one line.

Add a white-box regression test asserting both controllers size their
concurrency semaphore from MaxConcurrentRuns.
@google-cla

google-cla Bot commented Oct 4, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@github-actions

github-actions Bot commented Oct 4, 2026

Copy link
Copy Markdown

Thanks for the PR, @PratikDhanave.

One thing before review. Our contribution guide asks for an issue behind each change, and I could not find one linked here.

  • If an issue already exists, add Closes: #<number> or Related: #<number> to the description.
  • If not, open one — the link prefills the bug template with your PR title — then reference it the same way.

If this is a small doc or typo fix, ignore me. A maintainer can open the issue for you if you would rather.

@wolo-lab wolo-lab left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fixes the bug for the adkgo launcher, and the new test fails on main as it should. There is one case to handle before merge: a MaxConcurrentRuns of zero now makes every PubSub request hang.

make(chan struct{}, 0) returns an unbuffered channel, not nil, so the if c.semaphore != nil guard in PubSubTriggerHandler passes and the send blocks forever. The send doesn't watch r.Context(), so a client timeout or disconnect doesn't free the handler either. On main the same config returns 200 straight away, because a nil semaphore meant no limit.

The launcher isn't affected, since it rejects values ≤ 0 (launcher check). It does affect anyone calling NewPubSubControllerWithConfig or the deprecated NewPubSubController directly with a TriggerConfig that leaves the field unset. Both are released API and neither validates the value. A negative value makes the constructor panic.

NewEventarcControllerWithConfig already has the same behavior (eventarc.go), so copying it moved the problem over to PubSub. Whatever you pick (create the semaphore only when the value is positive, or return an error from the WithConfig constructor), please apply it to both controllers and add a test for the zero case.

…pacity

- pubsub.go: MaxConcurrentRuns<=0 now leaves semaphore nil ("no limit"),
  matching prior behavior. make(chan, 0) is an unbuffered channel (not nil),
  so the guard would pass and every request would block forever.
- Replace the capacity-only test with a behavioral one: fire MaxConcurrentRuns+1
  concurrent requests at a blocking agent and assert only MaxConcurrentRuns are
  in flight; add a regression test that MaxConcurrentRuns=0 does not hang. Moved
  into pubsub_test.go per review.
- fakes: make FakeSessionService concurrency-safe (mutex) so the new concurrency
  test is race-clean; no behavior change for existing sequential tests.
@PratikDhanave
PratikDhanave force-pushed the fix/pubsub-maxconcurrentruns-semaphore branch from 65501c6 to 564ef1b Compare October 6, 2026 04:33
@PratikDhanave

Copy link
Copy Markdown
Contributor Author

Thanks @wolo-lab — both addressed in the latest push.

The MaxConcurrentRuns == 0 hang. You're right: make(chan struct{}, 0) is an unbuffered channel, not nil, so the guard passed and every request blocked forever. Fixed by only allocating the semaphore when MaxConcurrentRuns > 0, leaving it nil (= no limit) otherwise — matching main's prior behavior:

var semaphore chan struct{}
if cfg.TriggerConfig.MaxConcurrentRuns > 0 {
    semaphore = make(chan struct{}, cfg.TriggerConfig.MaxConcurrentRuns)
}

The capacity-only test. Replaced it with a behavioral test in pubsub_test.go: it fires MaxConcurrentRuns + 1 concurrent requests at an agent that blocks, waits for exactly MaxConcurrentRuns runs to start, and asserts the extra one does not start until the others are released. Added a second regression test asserting MaxConcurrentRuns = 0 returns 200 and doesn't hang. Both fail on the respective pre-fix code and pass now.

Driving the handler concurrently surfaced that FakeSessionService wasn't concurrency-safe (its Sessions map), so I added a mutex to it — no behavior change for existing sequential tests. Whole package is green under -race.

(I scoped this to PubSub as requested; note EventarcController has the same unconditional make(chan, MaxConcurrentRuns), so it would hang on 0 too — happy to send a follow-up for it if you'd like.)

wolo-lab
wolo-lab previously approved these changes Oct 6, 2026

@wolo-lab wolo-lab left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, thanks!

@wolo-lab

wolo-lab commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

Please adjust the commit msg:

"Co-Authored-By: Claude Opus 4.8 (1M context) [email protected]"

It blocks a CLA check.

@PratikDhanave
PratikDhanave force-pushed the fix/pubsub-maxconcurrentruns-semaphore branch 2 times, most recently from 564ef1b to e696245 Compare October 7, 2026 07:44
@wolo-lab
wolo-lab merged commit 26495ae into google:main Oct 7, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants